diff --git a/.gitignore b/.gitignore index 277d7b4d9c..0a017869a0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ packages/tlon-skill/bin/tlon *.xst zod* bud +/backend/.cookie-*.txt vere-* *.pill /rube/zod/ @@ -52,3 +53,5 @@ clurd .obsidian .pi + +tmuxp.yaml diff --git a/AGENTS.md b/AGENTS.md index e510556c46..4a3cd53803 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,13 +6,36 @@ The backend of the Tlon Messenger app is hosted on the Urbit platform. All the backend code is located in the desk/ directory, which is deployed to an urbit ship. -## Development +## Development with tmux + +When explicitly instructed to interface with running ships with tmux, follow +the instructions in this section. If the user uses urbit MCP, use +dedicated workflows and documentation specific to urbit MCP. + Interface with a running urbit ship through a tmux session running an urbit ship. Do not switch to that session, but interface with it using tmux input and capture commands. A typical command to verify connection is working is `%`, which will display current identity, desk and time. +### Required backend validation + +Before treating a backend change as compiled or complete: + +1. Assemble the repository desk into the running ship's `%groups` Unix mount + with `scripts/assemble-desk.sh /groups`. Do not validate against a + hand-copied subset of `desk/`. +2. Send `|commit %groups` through the ship's tmux pane, wait for any `sync` + spinner to finish, and verify that the commit succeeds without build errors. +3. Run `-build-file` explicitly for every changed or newly added Hoon file that + might not yet be reachable from a live app or mark dependency. Use the + mounted desk path, for example + `=aut -build-file /=groups=/sur/steward/automation/hoon`. + +An equivalent expression entered directly in Dojo is not a substitute for +building the assembled repository file. Report the `|commit` and `-build-file` +results when claiming validation. + ## Backend documentation Comprehensive backend documentation can be found in `/docs/backend`. For system components, the directory structure mirrors that of a desk @@ -23,6 +46,10 @@ The documentation on the groups agent would thus be found at Always be sure to read documentation before answering any queries relevant to the backend or to backend tests. +## Backend developer tools +Tools useful for development are located under `/backend` directory. +Documentation can be found in `/docs/backend/tools`. + ## Backend tests There are two kinds of backend tests in groups. The first kind uses the `/lib/test-agent.hoon` library, which provides a monadic framework for @@ -44,3 +71,17 @@ Aqua tests are located in `/tests/ph` in the desk directory. For details on how to work with aqua tests see documentation in `/docs/backend/aqua`. + +## Git workflow + +For backend changes, strictly adhere to Linux Kernel commit commenting style of +`subsystem: imperative summary`. + +When implementing an OpenSpec change: + +- Ensure all relevant spec files have been committed. +- Commit each completed task separately after validation. +- Include the implementation and its task-checkbox update in the same commit. +- Use Linux kernel-style subjects, usually `subsystem: imperative summary`. +- Do not include unrelated working-tree changes. +- Do not mark or commit a task until its required validation passes. diff --git a/TLON-6042-internal-bot-rollout-path.md b/TLON-6042-internal-bot-rollout-path.md deleted file mode 100644 index 13bf8c22c6..0000000000 --- a/TLON-6042-internal-bot-rollout-path.md +++ /dev/null @@ -1,37 +0,0 @@ -# TLON-6042 Internal Bot Rollout Path - -Minimum path to get the `%notes` CLI functionality onto internal test bots: - -1. Confirm internal bot ships have the merged `%notes` backend installed. - - This is assumed satisfied for this rollout. - -2. Patch OpenClaw so the `tlon` tool can invoke the new notes command family. - - Add `notes` to the `ALLOWED_TLON_COMMANDS` set in `packages/openclaw/index.ts`. - - Keep `notebook` allowed for now so old notebook invocations reach the tlon skill and receive the skill's intentional deprecation/removal guidance instead of OpenClaw's generic unknown-subcommand error. - - Add/update `packages/openclaw/src/tlon-tool-guard.test.ts` coverage for `notes`. - -3. Update tlonbot prompts so agents stop reaching for notebook/diary commands. - - Replace prompt references to `diary/` channel format and `tlon notebook ...`. - - Add a concise `%notes` quick-reference entry using `tlon notes ...`. - -4. Publish package dependencies needed by hosted bot deployment. - - Hosted bot deployment resolves `packages/openclaw` workspace dependencies from npm, not from the `develop` checkout. - - Publish bumped `@tloncorp/api` and `@tloncorp/tlon-skill` versions before restarting bots. - - Current published versions match the local package versions, so version bumps are required before publish. - -5. Land the OpenClaw patch on `develop`. - - Internal monorepo deployments use the `packages/openclaw` source from `develop`. - - A `packages/openclaw/**` change also triggers the tlonbot smoke dispatch path after merge. - -6. Restart internal bot ships. - - Use the `Restart Bot Ships` workflow with `mode=internal` after the relevant npm publishes are available. - - If the OpenClaw source patch lands before the npm publishes, restart again after publish. - -7. Verify on an internal bot. - - Check the installed tlon skill version. - - Run `tlon notes status` and `tlon notes list`. - - Run a legacy `tlon notebook ...` invocation and confirm it returns the skill-level notebook removal guidance. - -Out of scope for this minimum path: - -- Updating OpenClaw channel target parsing, monitoring, and message routing to treat notes channels as first-class channel targets. That is separate from exposing the `tlon notes` CLI family to bots. diff --git a/backend/gen-moon.sh b/backend/gen-moon.sh new file mode 100755 index 0000000000..d3460efceb --- /dev/null +++ b/backend/gen-moon.sh @@ -0,0 +1,352 @@ +#!/bin/bash + +set -eu + +# Always run in ./backend so the cookie cache has a predictable location. +# Preserve the caller's directory so a relative boot directory is resolved as +# users expect rather than relative to ./backend. +caller_dir=$PWD +cd -- "$(dirname -- "${BASH_SOURCE[0]}")" + +fatal() { + echo "❌ $1" >&2 + exit 1 +} + +# Find a given file. If not found, download it. +# +# Arguments: +# $1 - url +# $2 - file +# +find_download() { + + if (($# < 2)) + then + fatal "find_download(): not enough args" + fi + + local url=$1 + local file=$2 + + if [[ ! -e "$file" ]] + then + echo "Downloading $file" + curl -# -f $url -o $file + return $? + fi + + return 0 +} + +usage() { + cat < + +Generate a moon for a live-network ship. Supply the ship without a leading ~. + +Options: + -b boot the generated moon + -d create the booted moon's pier under this directory (implies -b) + -t use Tlon hosted mode (tlon.network instead of arvo.network) +EOF +} + +boot=false +hosted=false +boot_dir=. + +while getopts ":bd:t" opt +do + case "$opt" in + b) + boot=true + ;; + d) + boot=true + boot_dir=$OPTARG + ;; + t) + hosted=true + ;; + :) + usage >&2 + exit 2 + ;; + \?) + usage >&2 + exit 2 + ;; + esac +done + +shift "$((OPTIND - 1))" + +if (( $# != 1 )) +then + usage >&2 + exit 1 +fi + +ship=$1 + +if [[ $boot_dir != /* ]] +then + boot_dir="$caller_dir/$boot_dir" +fi + +if [[ ! $ship =~ ^[a-z-]+$ ]] +then + fatal "Invalid ship '$ship'; expected a name such as sampel-palnet" +fi + +ship_name=$ship +ship_id="~$ship_name" +if $hosted +then + EYRE_HOST="${ship_name}.tlon.network" +else + EYRE_HOST="${ship_name}.arvo.network" +fi +EYRE_URL="https://${EYRE_HOST}" +COOKIE_FILE=".cookie-${ship_name}.txt" + +# Authenticate with Eyre unless a cached session is available. The password is +# read without echoing and sent over stdin so it does not appear in argv. +eyre_auth() { + + if [[ -s $COOKIE_FILE ]] + then + if awk -v host="$EYRE_HOST" -v name="urbauth-$ship_id" -v now="$(date +%s)" \ + '$1 == host && $6 == name && ($5 == 0 || $5 > now) { found=1 } END { exit !found }' \ + "$COOKIE_FILE" + then + chmod 600 "$COOKIE_FILE" + return 0 + fi + rm -f "$COOKIE_FILE" + fi + + local password + local cookie_tmp + + printf "Password for %s: " "$ship_id" >&2 + if ! IFS= read -r -s password + then + printf '\n' >&2 + fatal "Unable to read password" + fi + printf '\n' >&2 + + if [[ -z $password ]] + then + fatal "Password cannot be empty" + fi + + umask 077 + cookie_tmp=$(mktemp "${COOKIE_FILE}.tmp.XXXXXX") + + if ! printf 'password=%s' "$password" | \ + curl --fail --silent --show-error --max-time 30 \ + --cookie-jar "$cookie_tmp" \ + --data-binary @- \ + "$EYRE_URL/~/login" > /dev/null + then + unset password + rm -f "$cookie_tmp" + fatal "Failed to authenticate with $ship_id at $EYRE_URL" + fi + unset password + + if ! awk -v name="urbauth-$ship_id" '$6 == name { found=1 } END { exit !found }' "$cookie_tmp" + then + rm -f "$cookie_tmp" + fatal "Eyre did not return an authentication cookie for $ship_id" + fi + + mv -f "$cookie_tmp" "$COOKIE_FILE" + chmod 600 "$COOKIE_FILE" +} + +# Execute a thread through Eyre's HTTP API. +# +# run_thread +# +# Prints the JSON response to standard output on success. If a cached cookie is +# rejected, removes it, asks for the password, and retries once. +run_thread() { + + if (( $# != 5 )) + then + fatal "run_thread(): expected " + fi + + local desk=$1 + local input_mark=$2 + local thread=$3 + local output_mark=$4 + local json_input=$5 + local response_file + local status + local attempt + + response_file=$(mktemp) + + for attempt in 1 2 + do + eyre_auth + + if ! status=$(curl --silent --show-error --max-time 600 \ + --cookie "$COOKIE_FILE" \ + --cookie-jar "$COOKIE_FILE" \ + --header "Content-Type: application/json" \ + --header "Accept: application/json" \ + --request POST \ + --data-binary "$json_input" \ + --output "$response_file" \ + --write-out "%{http_code}" \ + "$EYRE_URL/spider/$desk/$input_mark/$thread/$output_mark") + then + rm -f "$response_file" + fatal "Failed to execute $desk/$thread through Eyre" + fi + + if [[ $status =~ ^2[0-9][0-9]$ ]] + then + cat "$response_file" + rm -f "$response_file" + return 0 + fi + + if (( attempt == 1 )) && [[ $status == 401 || $status == 403 ]] + then + rm -f "$COOKIE_FILE" + continue + fi + + cat "$response_file" >&2 + rm -f "$response_file" + fatal "Thread $desk/$thread failed with HTTP $status" + done +} + +vere_url="https://bootstrap.urbit.org/vere/live" +vere_ver="v4.6" + +arch=`uname -m` +platform="" + +case $OSTYPE in + linux*) + platform=linux + case $arch in + x86_64) + arch=x86_64 + ;; + arm64 | aarch64) + arch=aarch64 + ;; + *) + fatal "Unsupported arch $arch" + esac ;; + darwin*) + platform=macos + case $arch in + x86_64) + arch=x86_64 + ;; + arm64) + arch=arm64 + ;; + *) + fatal "Unsupported arch $arch" + ;; + esac ;; + *) + fatal "Unsupported platform $OSTYPE" + ;; +esac + + +if [[ -z $platform ]] +then + echo "Unsupported platform $OSTYPE" + exit 1 +fi + +if [[ -z $arch ]] +then + echo "Unsupported architecture $arch" + exit 1 +fi + +vere_bin="vere-$vere_ver-$platform-$arch" + +find_download "$vere_url/$vere_ver/$vere_bin" $vere_bin \ + || fatal "Failed to download $vere_bin" +vere="./$vere_bin" + +if [[ ! -x $vere_bin ]]; then chmod +x $vere_bin; fi + +boot_moon() { + + if (( $# != 2 )) + then + fatal "boot_moon(): expected the gen-moon JSON response and boot directory" + fi + + if ! command -v jq > /dev/null + then + fatal "jq is required to boot the generated moon" + fi + + local result=$1 + local moon_id + local moon_name + local moon_key + local key_file + local boot_root=$2 + local moon_path + + if ! moon_id=$(printf '%s' "$result" | jq -er \ + '.ship | strings | select(test("^~[a-z-]+$"))') + then + fatal "Thread response does not contain a valid moon ship" + fi + + if ! moon_key=$(printf '%s' "$result" | jq -er \ + '.key | strings | select(startswith("0w"))') + then + fatal "Thread response does not contain a valid moon key" + fi + + moon_name=${moon_id#\~} + mkdir -p -- "$boot_root" + moon_path="$boot_root/$moon_name" + if [[ -e $moon_path ]] + then + fatal "Cannot boot $moon_id: $moon_path already exists" + fi + + umask 077 + key_file=$(mktemp "${TMPDIR:-/tmp}/gen-moon-key.XXXXXX") + trap 'rm -f "$key_file"' EXIT + printf '%s\n' "$moon_key" > "$key_file" + + echo "Booting $moon_id in $moon_path/" >&2 + if ! $vere -w "$moon_name" -k "$key_file" -c "$moon_path" + then + fatal "Failed to boot $moon_id" + fi + + rm -f "$key_file" + trap - EXIT +} + +result=$(run_thread groups json gen-moon json null) +printf '%s\n' "$result" + +if $boot +then + boot_moon "$result" "$boot_dir" +fi diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index 1946e6b192..52b327992c 100644 --- a/desk/app/steward.hoon +++ b/desk/app/steward.hoon @@ -6,23 +6,32 @@ :: the bot itself runs steward as well as the bot's owner, so that things :: like lens data can be scried locally by the owner. :: -:: modules keep their own sur (sur/steward/{lens,gateway}.hoon) and marks -:: (%steward-{lens,gateway}-{action,update}-1); %steward-action-1 carries -:: only cross-cutting config (the shared owner). +:: modules keep their own sur +:: (sur/steward/{lens,gateway,automation}.hoon) and mark families; +:: %steward-action-1 carries only cross-cutting config (the shared owner). :: /- s=steward, a=activity, av=activity-ver, cv=chat-ver, st=story -/- sl=steward-lens, sg=steward-gateway +/- sl=steward-lens, sg=steward-gateway, sa=steward-automation /+ default-agent, verb, dbug |% +$ card card:agent:gall -:: %steward is greenfield (unreleased), so it has a single state version and -:: no migration — an unreadable state just resets to bunt. +:: versioned persisted state. state-0 is released and remains decodable for +:: migration. fresh installs and migrated agents use state-1. :: :: .owner: shared owner ship (lens send target, gateway owner-DM tracking) :: .bots: owner-side trusted bots — ships allowed to send lens %entry :: pokes cross-ship. explicit and ship-class-agnostic; an empty :: set means only local pokes are accepted. :: ++$ versioned-state $%(state-1 state-0) ++$ state-1 + $: %1 + owner=(unit ship) + bots=(set ship) + lens=state:v1:sl + gateway=state:v1:sg + automation=state:v1:sa + == +$ state-0 $: %0 owner=(unit ship) @@ -37,7 +46,7 @@ :: ++ default-max-runs-per-bot 3.000 -- -=| state-0 +=| state-1 =* state - %- agent:dbug %^ verb | %warn @@ -53,13 +62,25 @@ [~[watch-activity:cor] this] ++ on-save !>(state) ++ on-load - |= ole=vase - ^- (quip card _this) - :: greenfield single state — load it directly. an incompatible state is - :: only reachable pre-release; let it crash so we nuke rather than - :: silently wipe. + |^ |= ole=vase + ^- (quip card _this) + =/ old=versioned-state !<(versioned-state ole) + =? old ?=(%0 -.old) (state-0-to-1 old) + ?> ?=(%1 -.old) + `this(state old) + :: preserve every released field and initialize the new module empty :: - `this(state !<(state-0 ole)) + ++ state-0-to-1 + |= old=state-0 + ^- state-1 + :* %1 + owner.old + bots.old + lens.old + gateway.old + *state:v1:sa + == + -- ++ on-poke |= [=mark =vase] ^- (quip card _this) @@ -74,7 +95,6 @@ ++ on-peek |= =path ^- (unit (unit cage)) - ?> =(src our):bowl (peek:cor path) ++ on-agent |= [=wire =sign:agent:gall] @@ -125,6 +145,11 @@ :: %steward-gateway-action-1 (ga-poke-action:ga-core !<(action:v1:sg vase)) + :: + :: automation snapshots. authorization is enforced in au-poke-action + :: + %steward-automation-action-1 + (au-poke-action:au-core !<(action:v1:sa vase)) == :: ++ watch @@ -139,8 +164,9 @@ |= =path ^- (unit (unit cage)) ?+ path [~ ~] - [%x %v1 %lens *] (le-peek:le-core [%v1 t.t.t.path]) - [%x %v1 %gateway *] (ga-peek:ga-core [%v1 t.t.t.path]) + [%x %v1 %lens *] (le-peek:le-core [%v1 t.t.t.path]) + [%x %v1 %gateway *] (ga-peek:ga-core [%v1 t.t.t.path]) + [%x %v1 %automation *] (au-peek:au-core [%v1 t.t.t.path]) == :: ++ agent @@ -625,4 +651,41 @@ (ga-send-dm sender 'Your Tlon bot is offline right now, so replies are paused. I\'ll let you know when I\'m back. 🛰️') (ga-give-update [%auto-reply sender now.bowl]) -- +:: |au-core: automation projection module +:: +++ au-core + |% + ++ au-poke-action + |= =action:v1:sa + ^+ cor + ?> =(src.bowl our.bowl) + ?- -.action + %project + =/ projected (au-build-task-map tasks.action) + cor(tasks.automation.state projected) + == + ++ au-peek + |= =path + ^- (unit (unit cage)) + ?+ path [~ ~] + [%v1 %tasks ~] + ``steward-automation-task-map-1+!>(tasks.automation.state) + == + :: build the complete replacement before mutating state. a payload + :: with a duplicate ID crashes here, leaving the previous projection + :: untouched. + :: + ++ au-build-task-map + |= entries=(list identified-task:v1:sa) + ^- (map @t task:v1:sa) + =/ projected=(map @t task:v1:sa) *(map @t task:v1:sa) + |- + ?~ entries projected + =/ entry=identified-task:v1:sa i.entries + ?> ?=(~ (~(get by projected) id.entry)) + %= $ + entries t.entries + projected (~(put by projected) id.entry task.entry) + == + -- -- diff --git a/desk/lib/steward/automation-json.hoon b/desk/lib/steward/automation-json.hoon new file mode 100644 index 0000000000..458c434149 --- /dev/null +++ b/desk/lib/steward/automation-json.hoon @@ -0,0 +1,184 @@ +:: json conversion helpers for steward automation marks +:: +:: pinned OpenClaw exposes the %at schedule's `at` as an ISO string. the +:: normalized Steward boundary deliberately uses integer Unix milliseconds +:: under the same `at` key, so all absolute dates cross this boundary as +:: integers and the TypeScript normalizer owns ISO parsing. +:: +/- a=steward-automation +/+ au=steward-automation +|% +++ dejs + =, dejs:format + |% + ++ duration + (cu milliseconds-to-duration:au ni) + ++ date + (cu unix-milliseconds-to-date:au ni) + ++ optional + |* [key=@t wit=$-(json *) jon=json] + ?> ?=([%o *] jon) + =/ value (~(get by p.jon) key) + ?~ value ~ + (some (wit u.value)) + ++ schedule + :: schedules from OpenClaw use a `kind` field, not a tagged JSON object + |= jon=json + ^- cron-schedule:v1:a + ?> ?=([%o *] jon) + =/ kind (so (~(got by p.jon) 'kind')) + ?: =('cron' kind) + :* %cron + (optional 'expr' so jon) + (optional 'tz' so jon) + (optional 'staggerMs' duration jon) + == + ?: =('at' kind) + [%at (optional 'at' date jon)] + ?: =('every' kind) + :* %every + (optional 'everyMs' duration jon) + (optional 'anchorMs' date jon) + == + ~|(bad-schedule-kind+kind !!) + ++ payload + |= jon=json + ^- task-payload:v1:a + :* (optional 'kind' so jon) + (optional 'message' so jon) + == + ++ task + |= jon=json + ^- task:v1:a + :* (optional 'agentId' so jon) + (optional 'name' so jon) + (optional 'description' so jon) + (optional 'enabled' bo jon) + (optional 'schedule' schedule jon) + (optional 'sessionTarget' so jon) + (optional 'wakeMode' so jon) + (optional 'payload' payload jon) + (optional 'createdAtMs' date jon) + (optional 'updatedAtMs' date jon) + == + ++ identified-task + |= jon=json + ^- identified-task:v1:a + ?> ?=([%o *] jon) + =/ id-json=json (~(got by p.jon) 'id') + [(so id-json) (task jon)] + ++ project + |= jon=json + ^- (list identified-task:v1:a) + =/ tasks=(list identified-task:v1:a) + ((ot tasks+(ar identified-task) ~) jon) + =/ remaining tasks + =/ seen=(set @t) *(set @t) + |- + ?~ remaining tasks + ?> !(~(has in seen) id.i.remaining) + %= $ + remaining t.remaining + seen (~(put in seen) id.i.remaining) + == + ++ action + |= jon=json + ^- action:v1:a + %. jon + (of ~[[%project project]]) + ++ task-map + |= jon=json + ^- task-map:v1:a + ((ot tasks+(om task) ~) jon) + -- +:: +++ enjs + =, enjs:format + |% + ++ schedule + |= schedule=cron-schedule:v1:a + ^- json + ?- -.schedule + %cron + =/ fields=(list [@t json]) ~[['kind' s+'cron']] + =. fields ?~(expr.schedule fields [['expr' s+u.expr.schedule] fields]) + =. fields ?~(tz.schedule fields [['tz' s+u.tz.schedule] fields]) + =. fields + ?~ stagger.schedule + fields + [['staggerMs' (numb (duration-to-milliseconds:au u.stagger.schedule))] fields] + (pairs fields) + :: + %at + =/ fields=(list [@t json]) ~[['kind' s+'at']] + =. fields + ?~ at.schedule + fields + [['at' (numb (date-to-unix-milliseconds:au u.at.schedule))] fields] + (pairs fields) + :: + %every + =/ fields=(list [@t json]) ~[['kind' s+'every']] + =. fields + ?~ every.schedule + fields + [['everyMs' (numb (duration-to-milliseconds:au u.every.schedule))] fields] + =. fields + ?~ anchor.schedule + fields + [['anchorMs' (numb (date-to-unix-milliseconds:au u.anchor.schedule))] fields] + (pairs fields) + == + ++ payload + |= payload=task-payload:v1:a + ^- json + =/ fields=(list [@t json]) ~ + =. fields ?~(kind.payload fields [['kind' s+u.kind.payload] fields]) + =. fields ?~(message.payload fields [['message' s+u.message.payload] fields]) + (pairs fields) + ++ task + |= =task:v1:a + ^- json + =/ fields=(list [@t json]) ~ + =. fields ?~(agent-id.task fields [['agentId' s+u.agent-id.task] fields]) + =. fields ?~(name.task fields [['name' s+u.name.task] fields]) + =. fields + ?~(description.task fields [['description' s+u.description.task] fields]) + =. fields ?~(enabled.task fields [['enabled' b+u.enabled.task] fields]) + =. fields + ?~(schedule.task fields [['schedule' (schedule u.schedule.task)] fields]) + =. fields + ?~ session-target.task + fields + [['sessionTarget' s+u.session-target.task] fields] + =. fields ?~(wake-mode.task fields [['wakeMode' s+u.wake-mode.task] fields]) + =. fields + ?~(payload.task fields [['payload' (payload u.payload.task)] fields]) + =. fields + ?~ created-at.task + fields + [['createdAtMs' (numb (date-to-unix-milliseconds:au u.created-at.task))] fields] + =. fields + ?~ updated-at.task + fields + [['updatedAtMs' (numb (date-to-unix-milliseconds:au u.updated-at.task))] fields] + (pairs fields) + ++ identified-task + |= entry=identified-task:v1:a + ^- json + =/ jon=json (task task.entry) + ?> ?=([%o *] jon) + [%o (~(put by p.jon) 'id' [%s id.entry])] + ++ action + |= =action:v1:a + ^- json + ?- -.action + %project + (frond 'project' (frond 'tasks' a+(turn tasks.action identified-task))) + == + ++ task-map + |= tasks=task-map:v1:a + ^- json + (frond 'tasks' [%o (~(run by tasks) task)]) + -- +-- diff --git a/desk/lib/steward/automation.hoon b/desk/lib/steward/automation.hoon new file mode 100644 index 0000000000..cf9357efc0 --- /dev/null +++ b/desk/lib/steward/automation.hoon @@ -0,0 +1,32 @@ +:: time conversions for the steward automation protocol +:: +:: task definitions from OpenClaw represent absolute dates and durations as +:: integer milliseconds. these wrappers use the standard conversions supplied +:: by zuse. +:: +|% +:: +milliseconds-to-duration: convert integer milliseconds to an Urbit duration +:: +++ milliseconds-to-duration + |= milliseconds=@ud + ^- @dr + `@dr`(div (mul milliseconds ~s1) 1.000) +:: +duration-to-milliseconds: convert an Urbit duration to integer milliseconds +:: +++ duration-to-milliseconds + |= duration=@dr + ^- @ud + (msec:milly duration) +:: +unix-milliseconds-to-date: convert Unix epoch milliseconds to an Urbit date +:: +++ unix-milliseconds-to-date + |= milliseconds=@ud + ^- @da + (from-unix-ms:chrono:userlib milliseconds) +:: +date-to-unix-milliseconds: convert an Urbit date to Unix epoch milliseconds +:: +++ date-to-unix-milliseconds + |= date=@da + ^- @ud + (unm:chrono:userlib date) +-- diff --git a/desk/mar/steward/automation/action-1.hoon b/desk/mar/steward/automation/action-1.hoon new file mode 100644 index 0000000000..00b489ec90 --- /dev/null +++ b/desk/mar/steward/automation/action-1.hoon @@ -0,0 +1,17 @@ +:: %steward-automation-action-1: complete task projection action +:: +/- a=steward-automation +/+ aj=steward-automation-json +|_ =action:v1:a +++ grad %noun +++ grow + |% + ++ noun action + ++ json (action:enjs:aj action) + -- +++ grab + |% + ++ noun action:v1:a + ++ json action:dejs:aj + -- +-- diff --git a/desk/mar/steward/automation/task-map-1.hoon b/desk/mar/steward/automation/task-map-1.hoon new file mode 100644 index 0000000000..709dd4119a --- /dev/null +++ b/desk/mar/steward/automation/task-map-1.hoon @@ -0,0 +1,17 @@ +:: %steward-automation-task-map-1: an ID-keyed automation scry result +:: +/- a=steward-automation +/+ aj=steward-automation-json +|_ tasks=task-map:v1:a +++ grad %noun +++ grow + |% + ++ noun tasks + ++ json (task-map:enjs:aj tasks) + -- +++ grab + |% + ++ noun task-map:v1:a + ++ json task-map:dejs:aj + -- +-- diff --git a/desk/sur/steward/automation.hoon b/desk/sur/steward/automation.hoon new file mode 100644 index 0000000000..444c76e7be --- /dev/null +++ b/desk/sur/steward/automation.hoon @@ -0,0 +1,57 @@ +:: steward automation module: mirrored OpenClaw task definitions +:: +|% +:: $cron-schedule: the supported OpenClaw schedule variants; OpenClaw uses +:: integer milliseconds at the boundary, while the Hoon representation stores +:: dates and durations in their native atom types +:: ++$ cron-schedule + $% [%cron expr=(unit @t) tz=(unit @t) stagger=(unit @dr)] + [%at at=(unit @da)] + [%every every=(unit @dr) anchor=(unit @da)] + == +:: $task-payload: the definition fields of an OpenClaw task payload +:: ++$ task-payload + $: kind=(unit @t) + message=(unit @t) + == +:: $task: the supported definition-only PluginHookGatewayCronJob subset; the ID +:: from OpenClaw is stored separately as the map key. runtime job state and +:: execution history are not represented +:: ++$ task + $: agent-id=(unit @t) + name=(unit @t) + description=(unit @t) + enabled=(unit ?) + schedule=(unit cron-schedule) + session-target=(unit @t) + wake-mode=(unit @t) + payload=(unit task-payload) + created-at=(unit @da) + updated-at=(unit @da) + == +:: $identified-task: an inbound task paired with its OpenClaw ID +:: ++$ identified-task + $: id=@t + =task + == +:: $state: the latest complete task projection, keyed by OpenClaw task ID +:: ++$ state + $: tasks=(map @t task) + == +:: $action: inbound automation actions from the local harness +:: +:: %project: atomically replace the complete task projection +:: ++$ action + $% [%project tasks=(list identified-task)] + == +:: $task-map: the ID-keyed task map returned by the automation scry +:: ++$ task-map (map @t task) +++ v1 . +-- diff --git a/desk/ted/gen-moon.hoon b/desk/ted/gen-moon.hoon new file mode 100644 index 0000000000..173e6a5d34 --- /dev/null +++ b/desk/ted/gen-moon.hoon @@ -0,0 +1,38 @@ +/- spider +/+ strandio +=, strand=strand:spider +^- thread:spider +|= arg=vase +=/ m (strand ,vase) +^- form:m +=+ !<(arg=(unit json) arg) +?> ?=(^ arg) +?> ?=(%~ u.arg) +;< =bowl:spider bind:m get-bowl:strandio +=/ ran (clan:title our.bowl) +?: ?=([?(%earl %pawn)] ran) + %+ strand-fail:strand %invalid-parent-rank + :_ ~ + :- %leaf + "can't create a moon from a {?:(?=(%earl ran) "moon" "comet")}" +=/ mon=ship + (add our.bowl (lsh 5 (end 5 (shaz eny.bowl)))) +;< ryf=(unit rift) bind:m + (scry:strandio (unit rift) /j/ryft/(scot %p mon)) +?^ ryf + %+ strand-fail:strand %moon-already-exists + :~ leaf+"can't create {(scow %p mon)}, it already exists." + leaf+"use |moon-breach and/or |moon-cycle-keys instead." + == +=/ cic (pit:nu:cric:crypto 512 (shaz (jam mon life=1 eny.bowl)) %b ~) +=/ =feed:jael + [[%2 ~] mon rift=0 [life=1 sec:ex:cic]~] +;< ~ bind:m + %- send-raw-card:strandio + [%pass /ted/gen-moon %arvo %j %moon mon *id:block:jael %keys [1 1 pub:ex:cic] %.n] +=/ result=json + %- pairs:enjs:format + :~ ship+s+(scot %p mon) + key+s+(scot %uw (jam feed)) + == +(pure:m !>(result)) diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 7a3da118fc..66e1605897 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -1,15 +1,21 @@ -:: tests for %steward agent (lens module + gateway module) +:: tests for %steward agent modules :: /- s=steward, a=activity, av=activity-ver -/- l=steward-lens -/- g=steward-gateway -/+ *test-agent +/- l=steward-lens, g=steward-gateway, au=steward-automation +/+ *test-agent, aj=steward-automation-json /= agent /app/steward |% ++ dap %steward -:: agent state — single version (greenfield, no migration). `bots` is the -:: owner-side trusted set. +:: current state and the released state shape accepted by +on-load :: ++$ state-1 + $: %1 + owner=(unit ship) + bots=(set ship) + lens=state:v1:l + gateway=state:v1:g + automation=state:v1:au + == +$ state-0 $: %0 owner=(unit ship) @@ -21,6 +27,312 @@ :: ++ payload ^- json s+'run-record' ++ payload2 ^- json s+'partial' +++ automation-task + |= name=@t + ^- task:v1:au + :* ~ + `name + ~ + `& + ~ + ~ + ~ + ~ + ~ + ~ + == +++ project-automation + |= tasks=(list identified-task:v1:au) + =/ m (mare ,~) + ^- form:m + ;< * bind:m + (do-poke %steward-automation-action-1 !>(`action:v1:au`[%project tasks])) + (pure:m ~) +++ parse-json + |= body=@t + ^- json + (need (de:json:html body)) +++ project-automation-json + |= body=@t + =/ action=action:v1:au + (action:dejs:aj (parse-json body)) + (project-automation tasks.action) +++ trace-project-json + ^- @t + ''' + { + "project": { + "tasks": [ + { + "id": "trace-at-1", + "agentId": "dev", + "name": "Captured one-shot reminder", + "enabled": true, + "schedule": { + "kind": "at", + "at": 1785734301000 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a short reminder." + }, + "createdAtMs": 1785734006665, + "updatedAtMs": 1785734006665 + }, + { + "id": "trace-every-1", + "agentId": "dev", + "name": "Captured interval reminder", + "enabled": true, + "schedule": { + "kind": "every", + "everyMs": 120000, + "anchorMs": 1785735243782 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a playful reminder." + }, + "createdAtMs": 1785735243782, + "updatedAtMs": 1785740230441 + } + ] + } + } + ''' +++ trace-task-map-json + ^- @t + ''' + { + "tasks": { + "trace-at-1": { + "agentId": "dev", + "name": "Captured one-shot reminder", + "enabled": true, + "schedule": { + "kind": "at", + "at": 1785734301000 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a short reminder." + }, + "createdAtMs": 1785734006665, + "updatedAtMs": 1785734006665 + }, + "trace-every-1": { + "agentId": "dev", + "name": "Captured interval reminder", + "enabled": true, + "schedule": { + "kind": "every", + "everyMs": 120000, + "anchorMs": 1785735243782 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a playful reminder." + }, + "createdAtMs": 1785735243782, + "updatedAtMs": 1785740230441 + } + } + } + ''' +++ reconcile-initial-project-json + ^- @t + ''' + { + "project": { + "tasks": [ + { + "id": "daily-status", + "agentId": "main", + "name": "Daily status", + "enabled": true, + "schedule": { + "kind": "cron", + "expr": "0 9 * * *", + "tz": "UTC", + "staggerMs": 0 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send the daily status." + }, + "createdAtMs": 1785734000000, + "updatedAtMs": 1785734000000 + }, + { + "id": "disabled-reminder", + "agentId": "main", + "name": "Paused reminder", + "enabled": false, + "schedule": { + "kind": "every", + "everyMs": 120000, + "anchorMs": 1785735243782 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send the paused reminder." + }, + "createdAtMs": 1785735243782, + "updatedAtMs": 1785735243782 + } + ] + } + } + ''' +++ reconcile-initial-task-map-json + ^- @t + ''' + { + "tasks": { + "daily-status": { + "agentId": "main", + "name": "Daily status", + "enabled": true, + "schedule": { + "kind": "cron", + "expr": "0 9 * * *", + "tz": "UTC", + "staggerMs": 0 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send the daily status." + }, + "createdAtMs": 1785734000000, + "updatedAtMs": 1785734000000 + }, + "disabled-reminder": { + "agentId": "main", + "name": "Paused reminder", + "enabled": false, + "schedule": { + "kind": "every", + "everyMs": 120000, + "anchorMs": 1785735243782 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send the paused reminder." + }, + "createdAtMs": 1785735243782, + "updatedAtMs": 1785735243782 + } + } + } + ''' +++ reconcile-current-project-json + ^- @t + ''' + { + "project": { + "tasks": [ + { + "id": "daily-status", + "agentId": "main", + "name": "Daily status updated", + "enabled": true, + "schedule": { + "kind": "cron", + "expr": "30 9 * * *", + "tz": "UTC", + "staggerMs": 0 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send the updated daily status." + }, + "createdAtMs": 1785734000000, + "updatedAtMs": 1785740000000 + }, + { + "id": "one-shot-reminder", + "agentId": "main", + "name": "One-shot reminder", + "enabled": true, + "schedule": { + "kind": "at", + "at": 1785740301000 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send the one-shot reminder." + }, + "createdAtMs": 1785740000000, + "updatedAtMs": 1785740000000 + } + ] + } + } + ''' +++ reconcile-current-task-map-json + ^- @t + ''' + { + "tasks": { + "daily-status": { + "agentId": "main", + "name": "Daily status updated", + "enabled": true, + "schedule": { + "kind": "cron", + "expr": "30 9 * * *", + "tz": "UTC", + "staggerMs": 0 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send the updated daily status." + }, + "createdAtMs": 1785734000000, + "updatedAtMs": 1785740000000 + }, + "one-shot-reminder": { + "agentId": "main", + "name": "One-shot reminder", + "enabled": true, + "schedule": { + "kind": "at", + "at": 1785740301000 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send the one-shot reminder." + }, + "createdAtMs": 1785740000000, + "updatedAtMs": 1785740000000 + } + } + } + ''' :: :: our ship in tests is ~dev (set via +setup below). +moon stands in for a :: remote bot ship; the %entry gate is now an explicit trusted-bots set @@ -83,6 +395,253 @@ =/ =update:a [%add source t event] [/activity [~dev %activity] [%fact %activity-update-5 !>(`update:v9:av`update)]] :: +++ populate-released-slices + =/ m (mare ,~) + ^- form:m + ;< ~ bind:m (configure ~bus) + ;< ~ bind:m trust-moon + ;< * bind:m + (do-poke %steward-lens-action-1 !>(`action:v1:l`[%configure 17])) + ;< * bind:m + %- (do-as moon) + (do-poke %steward-lens-action-1 !>(`action:v1:l`[%entry 'migrated-run' payload &])) + ;< * bind:m + (do-poke %steward-gateway-action-1 !>(`action:v1:g`[%configure ~m7 ~m9])) + ;< * bind:m + %+ do-poke %steward-gateway-action-1 + !>(`action:v1:g`[%gateway-start 'migrated-boot' (add ~2024.1.1 ~m3)]) + ;< * bind:m (do-agent (make-dm-fact ~bus (add ~2024.1.1 ~s10))) + (pure:m ~) +:: +++ as-released-state + |= current=state-1 + ^- state-0 + :* %0 + owner.current + bots.current + lens.current + gateway.current + == +:: +++ assert-migrated-state + |= [old=state-0 current=state-1] + =/ m (mare ,~) + ^- form:m + ;< ~ bind:m (ex-equal !>(owner.current) !>(owner.old)) + ;< ~ bind:m (ex-equal !>(bots.current) !>(bots.old)) + ;< ~ bind:m (ex-equal !>(lens.current) !>(lens.old)) + ;< ~ bind:m (ex-equal !>(gateway.current) !>(gateway.old)) + (ex-equal !>(tasks.automation.current) !>(*(map @t task:v1:au))) +:: +:: ========================================================== +:: released state migration tests +:: ========================================================== +:: +++ test-migration-preserves-populated-released-state + %- eval-mare + =/ m (mare ,~) + ^- form:m + ;< ~ bind:m setup + ;< ~ bind:m populate-released-slices + ;< before-res=cage bind:m (got-peek /x/dbug/state) + =/ before=state-1 !<(state-1 !<(vase q.before-res)) + =/ old=state-0 (as-released-state before) + ;< caz=(list card) bind:m (do-load agent `!>(old)) + ;< ~ bind:m (ex-cards caz ~) + ;< after-res=cage bind:m (got-peek /x/dbug/state) + =/ after=state-1 !<(state-1 !<(vase q.after-res)) + (assert-migrated-state old after) +:: +++ test-migration-persists-through-current-save-load + %- eval-mare + =/ m (mare ,~) + ^- form:m + ;< ~ bind:m setup + ;< ~ bind:m populate-released-slices + ;< before-res=cage bind:m (got-peek /x/dbug/state) + =/ old=state-0 + (as-released-state !<(state-1 !<(vase q.before-res))) + ;< * bind:m (do-load agent `!>(old)) + ;< * bind:m (do-load agent ~) + ;< after-res=cage bind:m (got-peek /x/dbug/state) + =/ after=state-1 !<(state-1 !<(vase q.after-res)) + (assert-migrated-state old after) +:: +++ test-migration-malformed-state-fails-without-reset + %- eval-mare + =/ m (mare ,~) + ^- form:m + ;< ~ bind:m setup + ;< ~ bind:m populate-released-slices + ;< before-res=cage bind:m (got-peek /x/dbug/state) + =/ before=state-1 !<(state-1 !<(vase q.before-res)) + ;< ~ bind:m (ex-fail (do-load agent `!>([%0 'malformed']))) + ;< after-res=cage bind:m (got-peek /x/dbug/state) + =/ after=state-1 !<(state-1 !<(vase q.after-res)) + (ex-equal !>(after) !>(before)) +:: +:: ========================================================== +:: automation module tests +:: ========================================================== +:: +++ test-automation-project-populates-id-keyed-map + %- eval-mare + =/ m (mare ,~) + ^- form:m + =/ task-a=task:v1:au (automation-task 'Task A') + =/ task-b=task:v1:au (automation-task 'Task B') + ;< ~ bind:m setup + ;< res=cage bind:m (got-peek /x/dbug/state) + =/ st !<(state-1 !<(vase q.res)) + ;< ~ bind:m + (ex-equal !>(tasks.automation.st) !>(*(map @t task:v1:au))) + ;< ~ bind:m + (project-automation ~[['task-a' task-a] ['task-b' task-b]]) + ;< res=cage bind:m (got-peek /x/dbug/state) + =/ st !<(state-1 !<(vase q.res)) + =/ expected=(map @t task:v1:au) + %- ~(gas by *(map @t task:v1:au)) + ~[['task-a' task-a] ['task-b' task-b]] + (ex-equal !>(tasks.automation.st) !>(expected)) +:: +++ test-automation-project-repeats-omits-and-clears + %- eval-mare + =/ m (mare ,~) + ^- form:m + =/ task-a=task:v1:au (automation-task 'Task A') + =/ task-b=task:v1:au (automation-task 'Task B') + =/ both=(list identified-task:v1:au) + ~[['task-a' task-a] ['task-b' task-b]] + ;< ~ bind:m setup + ;< ~ bind:m (project-automation both) + ;< ~ bind:m (project-automation both) + ;< res=cage bind:m (got-peek /x/dbug/state) + =/ st !<(state-1 !<(vase q.res)) + =/ expected=(map @t task:v1:au) + %- ~(gas by *(map @t task:v1:au)) + both + ;< ~ bind:m (ex-equal !>(tasks.automation.st) !>(expected)) + ;< ~ bind:m (project-automation ~[['task-b' task-b]]) + ;< res=cage bind:m (got-peek /x/dbug/state) + =/ st !<(state-1 !<(vase q.res)) + =/ expected=(map @t task:v1:au) + (~(put by *(map @t task:v1:au)) 'task-b' task-b) + ;< ~ bind:m (ex-equal !>(tasks.automation.st) !>(expected)) + ;< ~ bind:m (project-automation ~) + ;< res=cage bind:m (got-peek /x/dbug/state) + =/ st !<(state-1 !<(vase q.res)) + (ex-equal !>(tasks.automation.st) !>(*(map @t task:v1:au))) +:: +++ test-automation-project-rejects-duplicate-without-mutation + %- eval-mare + =/ m (mare ,~) + ^- form:m + =/ task-a=task:v1:au (automation-task 'Task A') + =/ task-b=task:v1:au (automation-task 'Task B') + =/ initial=(list identified-task:v1:au) ~[['task-a' task-a]] + ;< ~ bind:m setup + ;< ~ bind:m (project-automation initial) + ;< ~ bind:m + (ex-fail (project-automation ~[['duplicate' task-a] ['duplicate' task-b]])) + ;< res=cage bind:m (got-peek /x/dbug/state) + =/ st !<(state-1 !<(vase q.res)) + =/ expected=(map @t task:v1:au) + (~(put by *(map @t task:v1:au)) 'task-a' task-a) + (ex-equal !>(tasks.automation.st) !>(expected)) +:: +++ test-automation-project-rejects-foreign-without-mutation + %- eval-mare + =/ m (mare ,~) + ^- form:m + =/ task-a=task:v1:au (automation-task 'Task A') + =/ initial=(list identified-task:v1:au) ~[['task-a' task-a]] + ;< ~ bind:m setup + ;< ~ bind:m (project-automation initial) + ;< ~ bind:m + %- ex-fail + %- (do-as ~zod) + (project-automation-json trace-project-json) + ;< res=cage bind:m (got-peek /x/dbug/state) + =/ st !<(state-1 !<(vase q.res)) + =/ expected=(map @t task:v1:au) + (~(put by *(map @t task:v1:au)) 'task-a' task-a) + (ex-equal !>(tasks.automation.st) !>(expected)) +:: +++ test-automation-tasks-scry-empty + %- eval-mare + =/ m (mare ,~) + ^- form:m + ;< ~ bind:m setup + ;< res=cage bind:m (got-peek /x/v1/automation/tasks) + ;< ~ bind:m + (ex-equal !>(p.res) !>(%steward-automation-task-map-1)) + =/ actual=task-map:v1:au !<(task-map:v1:au q.res) + (ex-equal !>(actual) !>(*(map @t task:v1:au))) +:: +++ test-automation-tasks-scry-populated-json + %- eval-mare + =/ m (mare ,~) + ^- form:m + =/ action=action:v1:au + (action:dejs:aj (parse-json trace-project-json)) + =/ projected=(list identified-task:v1:au) tasks.action + ;< ~ bind:m setup + ;< ~ bind:m (project-automation-json trace-project-json) + ;< res=cage bind:m (got-peek /x/v1/automation/tasks) + ;< ~ bind:m + (ex-equal !>(p.res) !>(%steward-automation-task-map-1)) + =/ actual=task-map:v1:au !<(task-map:v1:au q.res) + =/ expected=(map @t task:v1:au) + (~(gas by *(map @t task:v1:au)) projected) + ;< ~ bind:m (ex-equal !>(actual) !>(expected)) + %+ ex-equal + !>((task-map:enjs:aj actual)) + !>((parse-json trace-task-map-json)) +:: +++ test-automation-project-persists-through-save-load + %- eval-mare + =/ m (mare ,~) + ^- form:m + =/ action=action:v1:au + (action:dejs:aj (parse-json trace-project-json)) + =/ expected=(map @t task:v1:au) + (~(gas by *(map @t task:v1:au)) tasks.action) + ;< ~ bind:m setup + ;< ~ bind:m (project-automation-json trace-project-json) + ;< * bind:m (do-load agent ~) + ;< res=cage bind:m (got-peek /x/v1/automation/tasks) + ;< ~ bind:m + (ex-equal !>(p.res) !>(%steward-automation-task-map-1)) + =/ actual=task-map:v1:au !<(task-map:v1:au q.res) + (ex-equal !>(actual) !>(expected)) +:: +++ assert-automation-task-map-json + |= expected=@t + =/ m (mare ,~) + ^- form:m + ;< res=cage bind:m (got-peek /x/v1/automation/tasks) + ;< ~ bind:m + (ex-equal !>(p.res) !>(%steward-automation-task-map-1)) + =/ actual=task-map:v1:au !<(task-map:v1:au q.res) + (ex-equal !>((task-map:enjs:aj actual)) !>((parse-json expected))) +:: +++ test-automation-json-scry-reconciles-and-persists + %- eval-mare + =/ m (mare ,~) + ^- form:m + ;< ~ bind:m setup + ;< ~ bind:m + (project-automation-json reconcile-initial-project-json) + ;< ~ bind:m + (assert-automation-task-map-json reconcile-initial-task-map-json) + ;< * bind:m (do-load agent ~) + ;< ~ bind:m + (project-automation-json reconcile-current-project-json) + ;< ~ bind:m + (assert-automation-task-map-json reconcile-current-task-map-json) + ;< * bind:m (do-load agent ~) + (assert-automation-task-map-json reconcile-current-task-map-json) +:: :: ========================================================== :: LENS MODULE TESTS :: ========================================================== @@ -96,7 +655,7 @@ (do-poke %steward-action-1 !>(`action:v1:s`[%configure ~bus])) ;< ~ bind:m (ex-cards caz ~) ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) (ex-equal !>(owner.st) !>(`(unit ship)``~bus)) :: :: a completely foreign ship (not ourselves) must crash the local-only @@ -359,7 +918,7 @@ ;< * bind:m (do-poke %steward-lens-action-1 !>(`action:v1:l`[%configure 1])) ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) (ex-equal !>(~(wyt by runs.lens.st)) !>(1)) :: :: /x/v1/lens/since/[da] returns entries with received >= cutoff, newest @@ -485,9 +1044,9 @@ %- (do-as ~zod) (do-poke %steward-lens-action-1 !>(`action:v1:l`[%retry ~dev 'lens-r'])) :: -:: on-init subscribes to %activity and seeds the default retention cap +:: fresh initialization uses current state, seeds lens, and starts empty :: -++ test-init-arms-activity-and-cap +++ test-migration-fresh-initialization %- eval-mare =/ m (mare ,~) ^- form:m @@ -499,8 +1058,11 @@ :~ (ex-task /activity [~dev %activity] %watch /v5) == ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) - (ex-equal !>(max-runs-per-bot.lens.st) !>(`@ud`3.000)) + =/ st !<(state-1 !<(vase q.res)) + ;< ~ bind:m (ex-equal !>(-.st) !>(%1)) + ;< ~ bind:m + (ex-equal !>(max-runs-per-bot.lens.st) !>(`@ud`3.000)) + (ex-equal !>(tasks.automation.st) !>(*(map @t task:v1:au))) :: ++ test-watch-rejects-foreign-ship %- eval-mare @@ -510,24 +1072,6 @@ %- ex-fail %- (do-as ~zod) (do-watch /v1/lens) -:: +get-peek calls +on-peek bare (no +mock), so a ?> crash would take -:: down the runner; mule the calls directly instead of using +ex-fail -:: -++ test-peek-rejects-foreign-ship - %- eval-mare - =/ m (mare ,~) - ^- form:m - ;< ~ bind:m setup - ;< ~ bind:m (set-src ~zod) - |= s=state - =/ recent (mule |.((~(on-peek agent.s bowl.s) /x/v1/lens/recent))) - ?: ?=(%& -.recent) - |+~['expected foreign /x/v1/lens/recent peek to crash'] - =/ run (mule |.((~(on-peek agent.s bowl.s) /x/v1/lens/run/(scot %p ~zod)/lens-1))) - ?: ?=(%& -.run) - |+~['expected foreign /x/v1/lens/run peek to crash'] - &+[~ s] -:: :: ========================================================== :: GATEWAY MODULE TESTS :: ========================================================== @@ -549,7 +1093,7 @@ ^- form:m ;< ~ bind:m setup-gateway ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) ;< ~ bind:m (ex-equal !>(active-window.gateway.st) !>(~m5)) (ex-equal !>(reply-cooldown.gateway.st) !>(~m5)) :: @@ -575,7 +1119,7 @@ (ex-fact-paths ~[/v1/gateway]) == ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) ;< ~ bind:m (ex-equal !>(status.gateway.st) !>(%up)) (ex-equal !>(lease-until.gateway.st) !>(`lease-time)) :: @@ -593,7 +1137,7 @@ ;< * bind:m (do-poke %steward-gateway-action-1 !>(`action:v1:g`[%gateway-heartbeat 'boot-1' new-lease])) ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) ;< ~ bind:m (ex-equal !>(status.gateway.st) !>(%up)) ;< ~ bind:m (ex-equal !>(pending-restart.gateway.st) !>(|)) (ex-equal !>(lease-until.gateway.st) !>(`new-lease)) @@ -609,7 +1153,7 @@ ;< * bind:m (do-poke %steward-gateway-action-1 !>(`action:v1:g`[%gateway-stop 'boot-1' 'test'])) ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) ;< ~ bind:m (ex-equal !>(status.gateway.st) !>(%down)) (ex-equal !>(pending-restart.gateway.st) !>(&)) :: @@ -624,7 +1168,7 @@ ;< * bind:m (do-poke %steward-gateway-action-1 !>(`action:v1:g`[%gateway-stop 'boot-old' 'stale'])) ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) ;< ~ bind:m (ex-equal !>(status.gateway.st) !>(%up)) ;< ~ bind:m (ex-equal !>(boot-id.gateway.st) !>(`'boot-1')) (ex-equal !>(pending-restart.gateway.st) !>(|)) @@ -643,7 +1187,7 @@ ;< * bind:m (do-poke %steward-gateway-action-1 !>(`action:v1:g`[%gateway-heartbeat 'boot-1' new-lease])) ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) ;< ~ bind:m (ex-equal !>(status.gateway.st) !>(%down)) ;< ~ bind:m (ex-equal !>(boot-id.gateway.st) !>(~)) (ex-equal !>(pending-restart.gateway.st) !>(&)) @@ -659,7 +1203,7 @@ ;< ~ bind:m (wait ~s91) ;< * bind:m (do-arvo /gateway/lease-check [%behn %wake ~]) ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) ;< ~ bind:m (ex-equal !>(status.gateway.st) !>(%down)) (ex-equal !>(pending-restart.gateway.st) !>(&)) :: @@ -755,13 +1299,13 @@ ;< * bind:m (do-poke %steward-gateway-action-1 !>(`action:v1:g`[%gateway-stop 'boot-1' 'test'])) ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) ;< ~ bind:m (ex-equal !>(pending-restart.gateway.st) !>(&)) =/ lease-time-2 (add ~2024.1.1 ~m4) ;< * bind:m (do-poke %steward-gateway-action-1 !>(`action:v1:g`[%gateway-start 'boot-2' lease-time-2])) ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) ;< ~ bind:m (ex-equal !>(status.gateway.st) !>(%up)) (ex-equal !>(pending-restart.gateway.st) !>(|)) :: diff --git a/desk/tests/lib/steward-automation-json.hoon b/desk/tests/lib/steward-automation-json.hoon new file mode 100644 index 0000000000..5e4ab09731 --- /dev/null +++ b/desk/tests/lib/steward-automation-json.hoon @@ -0,0 +1,344 @@ +:: steward automation production JSON codec tests +:: +/- a=steward-automation +/+ *test, aj=steward-automation-json, au=steward-automation +|% +++ parse-json + |= body=@t + ^- json + (need (de:json:html body)) +++ parse-action + |= body=@t + ^- action:v1:a + (action:dejs:aj (parse-json body)) +++ empty-task + ^- task:v1:a + :* ~ + ~ + ~ + ~ + ~ + ~ + ~ + ~ + ~ + ~ + == +++ trace-at-task + ^- task:v1:a + :* (some 'dev') + (some 'Captured one-shot reminder') + ~ + (some %.y) + (some [%at (some (unix-milliseconds-to-date:au 1.785.734.301.000))]) + (some 'isolated') + (some 'now') + (some [(some 'agentTurn') (some 'Send a short reminder.')]) + (some (unix-milliseconds-to-date:au 1.785.734.006.665)) + (some (unix-milliseconds-to-date:au 1.785.734.006.665)) + == +++ trace-every-task + ^- task:v1:a + :* (some 'dev') + (some 'Captured interval reminder') + ~ + (some %.y) + (some [%every (some ~m2) (some (unix-milliseconds-to-date:au 1.785.735.243.782))]) + (some 'isolated') + (some 'now') + (some [(some 'agentTurn') (some 'Send a playful reminder.')]) + (some (unix-milliseconds-to-date:au 1.785.735.243.782)) + (some (unix-milliseconds-to-date:au 1.785.740.230.441)) + == +++ cron-task + ^- task:v1:a + :* (some 'dev') + (some 'Captured weekday reminder') + (some 'Captured cron expression fixture') + (some %.n) + (some [%cron (some '17 4 * * 1-5') (some 'America/New_York') (some ~s45)]) + (some 'isolated') + (some 'now') + (some [(some 'agentTurn') (some 'Send a weekday reminder.')]) + (some (unix-milliseconds-to-date:au 1.786.416.589.889)) + (some (unix-milliseconds-to-date:au 1.786.416.589.889)) + == +++ named-task + ^- task:v1:a + :* ~ + (some 'Named task') + ~ + ~ + ~ + ~ + ~ + ~ + ~ + ~ + == +++ trace-project-json + ^- @t + ''' + { + "project": { + "tasks": [ + { + "id": "trace-at-1", + "agentId": "dev", + "name": "Captured one-shot reminder", + "enabled": true, + "schedule": { + "kind": "at", + "at": 1785734301000 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a short reminder." + }, + "createdAtMs": 1785734006665, + "updatedAtMs": 1785734006665 + }, + { + "id": "trace-every-1", + "agentId": "dev", + "name": "Captured interval reminder", + "enabled": true, + "schedule": { + "kind": "every", + "everyMs": 120000, + "anchorMs": 1785735243782 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a playful reminder." + }, + "createdAtMs": 1785735243782, + "updatedAtMs": 1785740230441 + } + ] + } + } + ''' +++ trace-task-map-json + ^- @t + ''' + { + "tasks": { + "trace-at-1": { + "agentId": "dev", + "name": "Captured one-shot reminder", + "enabled": true, + "schedule": { + "kind": "at", + "at": 1785734301000 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a short reminder." + }, + "createdAtMs": 1785734006665, + "updatedAtMs": 1785734006665 + }, + "trace-every-1": { + "agentId": "dev", + "name": "Captured interval reminder", + "enabled": true, + "schedule": { + "kind": "every", + "everyMs": 120000, + "anchorMs": 1785735243782 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a playful reminder." + }, + "createdAtMs": 1785735243782, + "updatedAtMs": 1785740230441 + } + } + } + ''' +++ trace-action + ^- action:v1:a + [%project ~[['trace-at-1' trace-at-task] ['trace-every-1' trace-every-task]]] +++ trace-task-map + ^- task-map:v1:a + %- ~(gas by *(map @t task:v1:a)) + ~[['trace-at-1' trace-at-task] ['trace-every-1' trace-every-task]] +:: +:: the two production marks are deliberately thin wrappers around these +:: helpers. importing /mar files as test libraries is not supported by the +:: desk build, so these tests call the exact ++grab:json/++grow:json targets +:: +++ test-trace-derived-action-grab-and-grow + =/ actual=action:v1:a (parse-action trace-project-json) + ;: weld + (expect-eq !>(trace-action) !>(actual)) + %+ expect-eq + !>((parse-json trace-project-json)) + !>((action:enjs:aj actual)) + == +:: +:: this case is normalized from a live pinned OpenClaw cron-expression capture +:: +++ test-focused-cron-schedule-codec + =/ body=@t + ''' + { + "project": { + "tasks": [ + { + "id": "trace-cron-1", + "agentId": "dev", + "name": "Captured weekday reminder", + "description": "Captured cron expression fixture", + "enabled": false, + "schedule": { + "kind": "cron", + "expr": "17 4 * * 1-5", + "tz": "America/New_York", + "staggerMs": 45000 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a weekday reminder." + }, + "createdAtMs": 1786416589889, + "updatedAtMs": 1786416589889 + } + ] + } + } + ''' + =/ expected=action:v1:a [%project ~[['trace-cron-1' cron-task]]] + =/ actual=action:v1:a (parse-action body) + ;: weld + (expect-eq !>(expected) !>(actual)) + (expect-eq !>((parse-json body)) !>((action:enjs:aj actual))) + == +++ test-empty-action-grab-and-grow + =/ body=@t + ''' + { + "project": { + "tasks": [] + } + } + ''' + =/ actual=action:v1:a (parse-action body) + ;: weld + (expect-eq !>(`action:v1:a`[%project ~]) !>(actual)) + (expect-eq !>((parse-json body)) !>((action:enjs:aj actual))) + == +++ test-absent-optionals-roundtrip + =/ body=@t + ''' + { + "project": { + "tasks": [ + { + "id": "empty" + } + ] + } + } + ''' + =/ expected=action:v1:a [%project ~[['empty' empty-task]]] + =/ actual=action:v1:a (parse-action body) + ;: weld + (expect-eq !>(expected) !>(actual)) + %+ expect-eq + !>((parse-json body)) + !>((action:enjs:aj actual)) + == +++ test-invalid-json-rejected + =/ body=@t + ''' + { + "project": + ''' + %- expect-fail + |. (parse-action body) +++ test-duplicate-action-ids-rejected + %- expect-fail + |. %- parse-action + ''' + { + "project": { + "tasks": [ + { + "id": "same" + }, + { + "id": "same" + } + ] + } + } + ''' +++ test-invalid-schedule-kind-rejected + %- expect-fail + |. %- parse-action + ''' + { + "project": { + "tasks": [ + { + "id": "bad", + "schedule": { + "kind": "once" + } + } + ] + } + } + ''' +++ test-trace-task-map-grows-ids-as-keys-only + =/ actual=json (task-map:enjs:aj trace-task-map) + ;: weld + (expect-eq !>((parse-json trace-task-map-json)) !>(actual)) + (expect-eq !>(trace-task-map) !>((task-map:dejs:aj actual))) + == +++ test-populated-task-map-serializes-id-as-key-only + =/ tasks=(map @t task:v1:a) + (~(put by *(map @t task:v1:a)) 'map-id' named-task) + =/ expected=json + %- parse-json + ''' + { + "tasks": { + "map-id": { + "name": "Named task" + } + } + } + ''' + =/ actual=json (task-map:enjs:aj tasks) + ;: weld + (expect-eq !>(expected) !>(actual)) + (expect-eq !>(tasks) !>((task-map:dejs:aj actual))) + == +++ test-empty-task-map-serializes-as-empty-object + =/ tasks=task-map:v1:a *(map @t task:v1:a) + =/ expected=json + %- parse-json + ''' + { + "tasks": {} + } + ''' + ;: weld + (expect-eq !>(expected) !>((task-map:enjs:aj tasks))) + (expect-eq !>(tasks) !>((task-map:dejs:aj expected))) + == +-- diff --git a/desk/tests/lib/steward-automation.hoon b/desk/tests/lib/steward-automation.hoon new file mode 100644 index 0000000000..83c01d1fc4 --- /dev/null +++ b/desk/tests/lib/steward-automation.hoon @@ -0,0 +1,54 @@ +:: steward automation time conversion tests +:: +/+ *test, au=steward-automation +|% +++ test-duration-boundaries-and-roundtrips + ;: weld + %+ expect-eq + !>(`@dr`~s0) + !>((milliseconds-to-duration:au 0)) + :: + %+ expect-eq + !>(`@ud`0) + !>((duration-to-milliseconds:au (milliseconds-to-duration:au 0))) + :: + %+ expect-eq + !>(`@ud`1) + !>((duration-to-milliseconds:au (milliseconds-to-duration:au 1))) + :: + %+ expect-eq + !>(`@dr`~s1) + !>((milliseconds-to-duration:au 1.000)) + :: + %+ expect-eq + !>(`@ud`5.000) + !>((duration-to-milliseconds:au (milliseconds-to-duration:au 5.000))) + :: + %+ expect-eq + !>(`@ud`1.234) + !>((duration-to-milliseconds:au (milliseconds-to-duration:au 1.234))) + == +:: +++ test-unix-date-boundaries-and-roundtrips + ;: weld + %+ expect-eq + !>(~1970.1.1) + !>((unix-milliseconds-to-date:au 0)) + :: + %+ expect-eq + !>(`@ud`0) + !>((date-to-unix-milliseconds:au ~1970.1.1)) + :: + %+ expect-eq + !>(`@ud`1) + !> (date-to-unix-milliseconds:au (unix-milliseconds-to-date:au 1)) + :: + %+ expect-eq + !>(~2024.1.1) + !>((unix-milliseconds-to-date:au 1.704.067.200.000)) + :: + %+ expect-eq + !>(`@ud`1.704.067.200.000) + !> (date-to-unix-milliseconds:au (unix-milliseconds-to-date:au 1.704.067.200.000)) + == +-- diff --git a/docs/backend/README.md b/docs/backend/README.md index 139ebd765f..2f9d2b4128 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -1,3 +1,4 @@ This directory contains Tlon Messenger backend documentation. 1. `aqua/` contains aqua tests documentation -2. `desk/` contains documentation of backend components \ No newline at end of file +2. `desk/` contains documentation of backend components +3. `tools/` contains documentation of developer tools diff --git a/docs/backend/desk/app/steward.md b/docs/backend/desk/app/steward.md new file mode 100644 index 0000000000..245f1d59f0 --- /dev/null +++ b/docs/backend/desk/app/steward.md @@ -0,0 +1,266 @@ +# %steward + +Ship-native umbrella agent: the durable, always-on ship-side half of an ephemeral bot harness. A harness (openclaw, hermes, or any future harness) talks to one agent regardless of which features it uses. + +## concept: modules + +`%steward` is built around **modules**, each a cohesive feature area. Each module is independently versioned and owns its own protocol types and mark family, so modules can evolve without dragging each other along: + +| Module | sur file | marks | +|--------------|----------------------------------|--------------------------------------------------------------------------| +| (core) | `sur/steward.hoon` | `%steward-action-1` | +| `lens` | `sur/steward/lens.hoon` | `%steward-lens-action-1`, `%steward-lens-update-1` | +| `gateway` | `sur/steward/gateway.hoon` | `%steward-gateway-action-1`, `%steward-gateway-update-1` | +| `automation` | `sur/steward/automation.hoon` | `%steward-automation-action-1`, `%steward-automation-task-map-1` | + +Each sur file is versioned on its own (`++v1`), referenced by callers as `action:v1:lens`, `update:v1:gateway`, etc. The core `sur/steward.hoon` carries only cross-cutting config (currently just `%configure`); each module's protocol lives in its own file. + +Modules: + +| Module | Purpose | +|--------------|------------------------------------------------------------------------| +| `lens` | Per-run bot introspection (folded in from the former `%context-lens`). | +| `gateway` | Harness liveness tracking + offline DM auto-replies. | +| `automation` | Durable best-effort mirror of OpenClaw cron task definitions. | + +The app helper core keeps each module's logic in its own sub-core: `le-core` for lens, `ga-core` for gateway, and `au-core` for automation. Adding a new module means a new `sur/steward/.hoon`, its own mark family, and a dispatch arm in the app — existing modules and marks are untouched. + +## state model + +`%steward` is released and loads a `versioned-state` union. The released shape remains `state-0`; fresh installs and migrated agents use the current `state-1`: + +``` +state-0 (%0, released) + owner (unit ship) shared owner config; ~ = inert + bots (set ship) owner-side trusted lens bots + lens state:v1:lens stored lens run records + gateway state:v1:gateway liveness + auto-reply bookkeeping + +state-1 (%1, current) + owner (unit ship) copied unchanged from state-0 + bots (set ship) copied unchanged from state-0 + lens state:v1:lens copied unchanged from state-0 + gateway state:v1:gateway copied unchanged from state-0 + automation state:v1:automation + tasks (map @t task) latest accepted complete projection +``` + +`owner` is shared: the lens module sends runs to it, and the gateway module treats its DMs as owner activity worth auto-replying to. `bots` is the owner-side allowlist of ships permitted to fan lens runs in (see the `%entry` gate below); managed via the core `%trust-bot`/`%untrust-bot` pokes. + +`on-load` decodes the persisted vase as `versioned-state`. A current `%1` state is restored unchanged. Loading a released `%0` state runs the explicit `state-0-to-1` migration: `owner`, `bots`, `lens`, and `gateway` are copied unchanged, and `automation` starts with an empty task map. `on-save` always writes the current `state-1` shape, so a migrated state remains current on later save/load cycles. A malformed or unrecognized persisted state fails visibly during decode; it is not replaced with bunt state. This is intentional protection against silent loss of released Steward data. + +`run` (in `sur/steward/lens.hoon`): + +``` +complete ? whether a finalized (final=&) record has been received for this id +received @da when the latest poke for this id arrived +payload json the run record, stored as typed JSON +``` + +The lens payload is stored as a typed `$json` value (`enjs:format`/`dejs:format` on the wire). The gateway enforces size caps and truncation before poking; the ship relays and stores the parsed JSON without interpreting its contents, and re-serializes it on read. (Storing typed `$json` is fine — an earlier worry that embedding `$json` in a mark sample made ford's tube checks diverge turned out to be a mark-arm/type **shadowing** bug, not a property of `$json`. The mark captures the real type in an outer core, `=> |% +$ jsn json --`, and uses `same` as the json fist so the `++json` grow/grab arms don't shadow the `$json` type.) + +## module: lens + +Makes a bot's run records — trigger, tool calls, timings, output — durable on the owner's ship and reachable from any client (including mobile), without the client ever talking to the gateway. + +One agent, two roles; the same code runs on every ship, and the role is determined by **who poked it** (the `%steward-lens-action-1` ownership gate has already vetted the source — see below): + +- **bot ship role** (`src == our`): the local gateway pokes `%steward-lens-action-1` with a run record. `le-poke-action` sends it to the configured `owner` as a `%steward-lens-action-1` poke. Ames retries until ack, so owner-ship downtime or gateway restarts don't drop finalized runs once poked. +- **owner ship role** (`src` is a trusted bot — in our `bots` set): a bot sent us its run. `le-poke-action` stores it keyed `[bot=src id]`, gives a fact on `/v1/lens`, and answers scries for clients. + +A self-owned bot (`owner` equal to `our`) is stored directly during send with no network hop. + +The lens action is a tagged union of three shapes: + +- **`%entry`** `[%entry id=@t payload=json final=?]` — a run record from the gateway. `final=&` marks the run complete; `final=|` is an in-progress milestone that upserts a partial record. A finalized run is never demoted back to partial by a late `final=|` (the late partial is dropped). Oversized payloads (jammed size over 512KB) are dropped to bound loom usage. +- **`%retry`** `[%retry bot=ship id=@t]` — an owner-initiated request to re-dispatch a failed/aborted run. The symmetric case to `%entry` (bot → owner): retry flows owner → bot. If `bot == our`, the agent emits a `%retry-requested` fact on `/v1/lens` for the local gateway to act on; if `bot != our`, the owner's steward relays a cross-ship `%retry` poke to that bot's steward, which then emits the fact for its own gateway. Retry never mutates stored state — the gateway creates a fresh run and pokes it back via `%entry`. +- **`%configure`** `[%configure max-runs-per-bot=@ud]` — set the per-ship retention cap (local only); applied to every bot immediately. + +### retention + +Count-bounded only — lens runs are durable memory, not transient logs, so there is **no time-based expiry**. Each bot keeps at most `max-runs-per-bot` records (default 3,000, seeded at install; changed via `%configure`). When a bot exceeds the cap, the oldest by `received` are dropped. Enforced on every insert (bounds that bot's tail) and on `%configure` (re-applies a new cap to every bot). No prune timer. + +## module: gateway + +Tracks the liveness of an external harness process and sends offline DM auto-replies on the bot's behalf while it's down — the part the harness can't do for itself, since no harness code runs during downtime. Ported from the former standalone `%gateway-status` agent (now a thin proxy; see `backend/desk/app/gateway-status.md`). + +The harness reports its lifecycle via the gateway action: `%gateway-start` (with a `boot-id` and a lease expiry), periodic `%gateway-heartbeat`s that extend the lease, and a graceful `%gateway-stop`. A behn timer on `/gateway/lease-check` fires at the lease expiry; if no heartbeat renewed it, the gateway is marked `%down`. `boot-id` matching distinguishes graceful-stop recovery from crash recovery exactly as in the original agent (stop clears `boot-id` so late heartbeats can't revive it; crash/expiry retains it so a delayed heartbeat can). + +While the gateway is not live, a DM from the configured `owner` triggers a canned offline auto-reply to that ship (subject to a dedupe on the triggering message key and a `reply-cooldown`). Around stop/start transitions, a "restarting" / "back online" notice is sent to the owner if they messaged within `active-window`. Inbound owner DMs are observed via a subscription to `%activity /v5`. + +`owner` is the shared top-level `(unit ship)`, set via the core `%configure`. This matches `%gateway-status`'s original single-owner model. The gateway action's own `%configure` carries only timing (`active-window`, `reply-cooldown`); the owner is set once at the core level. + +## module: automation + +Stores the latest complete OpenClaw cron definition set successfully submitted by the local harness. OpenClaw remains authoritative for scheduling and execution; this module is a durable, locally readable, best-effort mirror and must not be treated as continuously fresh while the harness is offline or reconciliation is failing. + +The v1 state is `tasks=(map @t task)`. The OpenClaw job ID is used only as the map key. A stored `task` value has no ID field, so the ID is neither duplicated in state nor inside the JSON value returned by the scry. Every supported definition field is optional and retains its presence or absence: + +| Task field | Hoon value | JSON field | +|------------|------------|------------| +| agent assignment | `(unit @t)` | `agentId` | +| display metadata | `(unit @t)` for name and description | `name`, `description` | +| enabled state | `(unit ?)` | `enabled` | +| schedule | `(unit cron-schedule)` | `schedule` | +| execution target | `(unit @t)` for each value | `sessionTarget`, `wakeMode` | +| payload definition | optional `kind` and `message` | `payload` | +| definition timestamps | `(unit @da)` for each value | `createdAtMs`, `updatedAtMs` | + +Supported schedules are `cron` (`expr`, `tz`, and `staggerMs`), `at` (`at`), and `every` (`everyMs` and `anchorMs`). Millisecond duration and timestamp fields cross the JSON boundary as non-negative integer milliseconds. Pinned OpenClaw returns an `at` timestamp as ISO text; the TypeScript normalizer validates and converts it to Unix milliseconds before `%steward` receives it. + +### projection behavior + +The inbound action and outbound task map use separate, independently versioned marks so their JSON shapes can evolve separately. `%steward-automation-action-1` accepts one action, `%project`, from the local Gall source only (`src.bowl == our.bowl`). Its JSON shape is: + +```json +{ + "project": { + "tasks": [ + { + "id": "job-id", + "agentId": "main", + "name": "Daily status", + "enabled": false, + "schedule": { + "kind": "cron", + "expr": "0 9 * * *", + "tz": "UTC" + }, + "payload": { + "kind": "agentTurn", + "message": "Send the daily status." + } + } + ] + } +} +``` + +The list is the complete projection, not a delta. The action mark parses and validates the JSON fields, while `au-build-task-map` rejects duplicate IDs and constructs the entire replacement map before the agent assigns it to state. Any invalid field, unsupported schedule, duplicate ID, foreign source, or other validation failure leaves the previous map unchanged. A valid action replaces the whole map in one state transition: omitted IDs are removed, an empty `tasks` list clears the projection, and repeating the same logical snapshot produces the same state without duplicate records. After ingestion, each inbound `id` exists only as its map key. + +Automation intentionally has no mutation or owner administration surface and no subscription. It also excludes cron execution state, execution events, run history, delivery data, session keys, `deleteAfterRun`, and other runtime-only OpenClaw fields. Those values do not enter the Hoon task type or the automation JSON scry. + +### OpenClaw reconciliation + +The implementation targets pinned OpenClaw `2026.5.28`, which provides `gateway_start`, `cron_changed`, `gateway_stop`, and `getCron()`, but not `cron_reconciled`. On `gateway_start` and on every `cron_changed` action—including execution-related `started` and `finished` actions—the Tlon plugin calls `getCron().list({ includeDisabled: true })`, normalizes the complete result, and submits one `%project` poke. A genuinely successful empty list therefore clears the projection; unavailable cron access or a failed read does not masquerade as an empty list. + +The v1 adapter uses one process-global monitor connection slot, so projection is enabled only when exactly one Tlon account is runnable (enabled with ship, URL, and code configured). Additional disabled or incomplete entries do not disable projection. With zero or multiple runnable accounts, gateway-start and cron-change hooks start no projection work; a one-to-many transition stops the active reconciliation epoch on the next trigger and preserves the last stored snapshots instead of targeting whichever monitor most recently published its connection. + +Reconciliation work is serialized so the worker does not deliberately start overlapping snapshots. Triggers that arrive while listing or waiting for poke acknowledgement are coalesced into one follow-up read using the latest cron accessor. Cron access, normalization, connection, read, and poke-acknowledgement failures retry the complete operation after a delay while the gateway epoch remains active. Each read-and-submit attempt has a 30-second local deadline so a promise that never settles cannot permanently own the process-lifetime worker. A timed-out list is fenced before submission, and late promise rejection remains observed. Until a later operation succeeds, the ship retains its last successful projection. + +`gateway_stop` cancels retry delays, abandons the current operation wait, rejects queued work, and prevents new cron-change work without clearing Steward state. An epoch check immediately before invoking the poke adapter prevents a list from an ended gateway epoch from starting a stale submission. A later `gateway_start` begins a fresh epoch and complete read without waiting for an abandoned old-epoch promise to settle. The local deadline cannot revoke a remote side effect after a poke has already been issued; acknowledgement routing and transport behavior must account for that uncertain-outcome boundary. The reconciler itself lives in process-shared state and is reused across OpenClaw discovery, full activation, and prewarm registration passes; each pass binds its own hooks to that one process-lifetime worker. Projection errors and cron telemetry errors are observed independently, so neither path suppresses the other. + +These triggers repair missed changes when a later complete operation succeeds, but they do not provide exact continuous freshness. A process crash, missed event, offline OpenClaw instance, or repeated failure can leave the mirror stale. + +## poke surface + +Four inbound marks, each ownership-gated to admit exactly the right source. + +### `%steward-action-1` (core config) — `src == our` + +```json +{ "configure": { "owner": "~sampel-palnet" } } +``` + +``` +[%configure owner=ship] top-level: set the shared owner +[%trust-bot ship=ship] add a ship to the trusted-bots set +[%untrust-bot ship=ship] remove a ship from the trusted-bots set +``` + +`%trust-bot`/`%untrust-bot` manage the owner-side `bots` allowlist that gates lens `%entry` fan-in. Trust is explicit and ship-class-agnostic — a bot may be a planet, moon, comet, star, or galaxy, and moon sponsorship is **not** an auto-trust. + +### `%steward-lens-action-1` (lens) + +Auth is **per-variant**, since each shape expects a different `src`: + +- `%entry` — accepted iff `src` is `our`, or `src` is in the owner-side trusted-bots set (`bots`, granted via the core `%trust-bot` poke). Ship-class-agnostic: a trusted bot may be a planet, moon, comet, etc. Moon sponsorship is **not** an auto-trust — even a moon the owner sponsors must be explicitly `%trust-bot`'d. This is the one shape a trusted remote ship may submit (its own runs, stored keyed by `src`). +- `%retry` — accepted iff `src` is `our` (a local client, or an owner-side relay forwarding to its own bot when `bot == our`) or the configured `owner` (relaying a retry to its bot moon). +- `%configure` — `src == our` only. + +```json +{ "entry": { "id": "", "payload": { ... run record ... }, "final": true } } +{ "retry": { "bot": "~sampel-palnet", "id": "" } } +{ "configure": { "max-runs-per-bot": 10000 } } +``` + +``` +[%entry id=@t payload=json final=?] a lens run milestone (final=& finalizes) +[%retry bot=ship id=@t] owner-initiated re-dispatch request +[%configure max-runs-per-bot=@ud] set the per-bot retention cap +``` + +### `%steward-gateway-action-1` (gateway) — `src == our` + +Only the local gateway drives liveness, so this requires `src == our`. + +``` +[%configure active-window=@dr reply-cooldown=@dr] set notice/cooldown timing (owner set separately) +[%gateway-start boot-id=@t lease-until=@da] a gateway instance started +[%gateway-heartbeat boot-id=@t lease-until=@da] extend the lease (boot-id must match) +[%gateway-stop boot-id=@t reason=@t] graceful stop (boot-id must match) +``` + +### `%steward-automation-action-1` (automation) — `src == our` + +Only the local harness may replace the automation projection. + +``` +[%project tasks=(list identified-task:v1:automation)] +``` + +Each `identified-task` is `[id=@t task]` on the noun side. The mark's JSON form and complete-replacement behavior are described under [projection behavior](#projection-behavior). + +## subscription surface + +- `/v1/lens` (local only, `?> =(src our)`): `%steward-lens-update-1` facts (`update:v1:lens`, a tagged union) — `%entry` (a stored run, one per insert; the owner-side client reads these) and `%retry-requested` (emitted on the bot ship for its local gateway to re-dispatch). No initial backfill fact — clients scry `/x/v1/lens/recent` for backfill. +- `/v1/gateway` (local only): `%steward-gateway-update-1` facts (`update:v1:gateway`) — `%status` (on lifecycle transitions, plus an initial fact on subscribe), `%owner-activity`, and `%auto-reply`. +- Automation has no subscription surface. Clients read its complete map from the dedicated scry. + +## scry surface + +Dotket scries execute locally against the agent's current state and do not carry a foreign caller source to authorize. Lens scries return the `%steward-lens-update-1` mark so the HTTP client reads them as JSON. + +- `/x/v1/lens/recent` → `[%recent entries]` — newest 50 runs across all bots, for backfill. Grows to `{ "recent": [ entry, … ] }` (a JSON array of entry objects). +- `/x/v1/lens/recent/[count]` → `[%recent entries]` — newest `count` runs. +- `/x/v1/lens/since/[da]` → `[%recent entries]` — every run with `received >= da`, newest first; paginate history by passing the oldest `received` from the last page. +- `/x/v1/lens/run/[ship]/[id]` → `[%entry entry]`, or empty (`[~ ~]`) when absent. +- `/x/v1/gateway/status` → `%noun` `[status:v1:gateway (unit @da)]` — current liveness and lease expiry. +- `/x/v1/gateway/owner-activity` → `%noun` `@da` — timestamp of the most recent owner DM. +- `/x/v1/automation/tasks` → `%steward-automation-task-map-1` `(map @t task:v1:automation)` — the complete latest accepted automation projection. + +The automation task-map mark grows to a JSON object whose property names are the sole serialized task IDs: + +```json +{ + "tasks": { + "job-id": { + "agentId": "main", + "enabled": false, + "schedule": { "kind": "every", "everyMs": 60000 } + } + } +} +``` + +With no stored tasks the exact JSON shape is `{ "tasks": {} }`. Task values use the supported OpenClaw field names listed above, omit absent optional fields, and never contain `id` or runtime cron state. + +`entry` is `[bot=ship id=@t run]`. The `%entry` update grows to JSON for Eyre, embedding the stored payload directly: + +```json +{ "entry": { "bot": "~zod", "id": "...", "complete": true, "received": "~2026.6.10..12.00.00..0000", "payload": { ... run record ... } } } +``` + +## lifecycle and invariants + +- `on-init` creates `state-1`, subscribes to `%activity /v5` for the gateway module, seeds the default lens retention cap, and leaves automation empty. There is no lens prune timer (retention is count-only, enforced on insert/configure). +- `on-load` decodes `versioned-state`: current `state-1` loads directly and released `state-0` migrates through `state-0-to-1`. Decode or migration failure is visible and never resets to bunt. `on-save` writes `state-1`. +- Wires: lens send on `/lens/send/[owner-p]/[id-t]`, lens retry relay on `/lens/retry/[bot-p]/[id-t]`, the gateway lease timer on `/gateway/lease-check`, gateway auto-reply/notice DM sends on `/gateway/dm/send`. The `%activity` subscription is re-watched on `%kick`. Poke/DM nacks are logged and ignored (Ames retries). +- `on-watch` asserts `=(src our)`, so subscriptions are local-only. Dotket `on-peek` calls execute locally against current state without caller-source authorization. Core, gateway, and automation pokes are local only; lens applies its per-action source rules to admit trusted bot runs and owner relays. + +## integration notes + +- The gateway (openclaw-tlon / hermes) pokes core `%configure` on monitor activation and `%steward-lens-action-1` run milestones from its run event stream. Lens recording is config-gated on the gateway side (`channels.tlon.contextLens`). +- Clients store runs locally, subscribe to `/v1/lens` for live updates, and scry on cache miss. The channel post pointer blob carries `botShip` so the client knows which `[bot id]` key to look up. +- The gateway's HTTP/SSE routes remain an optional desktop enhancement for fine-grained live streaming; `%steward`'s lens module is the durable source of truth. +- The `%steward-lens-*` marks replace the former `%context-lens-*` marks. There is no separate cross-ship `signal` mark — sending reuses `%steward-lens-action-1`, gated by ownership. diff --git a/docs/backend/tools/README.md b/docs/backend/tools/README.md new file mode 100644 index 0000000000..6a1b4b2e7b --- /dev/null +++ b/docs/backend/tools/README.md @@ -0,0 +1,45 @@ +# Backend developer tools + +The following tools are available to aid development on Urbit: + +- `backend/run-tests.sh` — run the backend unit and Aqua test suites. +- `backend/update-pill.sh` — generate a pill containing specified `%base` and `%groups` desks. +- `backend/gen-moon.sh` — generate a moon. +- `scripts/assemble-desk.sh ` — assemble a complete `%groups` desk by layering `desk-deps/` and `desk/` into the target. + +## Validating repository Hoon on a running ship + +Use the complete assembled desk whenever repository changes are tested on a +running ship. If the ship's pier is at ``, synchronize the Unix mount with: + +```sh +./scripts/assemble-desk.sh /groups +``` + +The script synchronizes vendored dependencies, clears stale files from the +target, overlays repository sources, and stamps `commit.txt`. Point it only at +the intended `%groups` Unix mount. + +Without switching to the ship's tmux session, send: + +```text +|commit %groups +``` + +Wait for any `sync` spinner to finish and inspect the captured output. A +successful desk commit is required before a backend change is considered +validated. + +A desk commit only builds files reachable from live dependencies. New files and +files not yet imported by an app or mark therefore require an explicit manual +build. For each such changed Hoon file, run `-build-file` against its mounted +path, for example: + +```text +=aut -build-file /=groups=/sur/steward/automation/hoon +``` + +Use a distinct Dojo face when building more than one file. Confirm that each +command returns a value without a build error. Compiling an equivalent inline +expression does not validate the repository file and is not a substitute for +this step. diff --git a/docs/steward.md b/docs/steward.md deleted file mode 100644 index c2eb05aaa2..0000000000 --- a/docs/steward.md +++ /dev/null @@ -1,164 +0,0 @@ -# %steward - -Ship-native umbrella agent: the durable, always-on ship-side half of an ephemeral bot harness. A harness (openclaw, hermes, or any future harness) talks to one agent regardless of which features it uses. - -## concept: modules - -`%steward` is built around **modules**, each a cohesive feature area. Each module is independently versioned and owns its own protocol types and mark family, so modules can evolve without dragging each other along: - -| Module | sur file | marks | -|-----------|---------------------------|--------------------------------------------------------| -| (core) | `sur/steward.hoon` | `%steward-action-1` | -| `lens` | `sur/steward/lens.hoon` | `%steward-lens-action-1`, `%steward-lens-update-1` | -| `gateway` | `sur/steward/gateway.hoon`| `%steward-gateway-action-1`, `%steward-gateway-update-1` | - -Each sur file is versioned on its own (`++v1`), referenced by callers as `action:v1:lens`, `update:v1:gateway`, etc. The core `sur/steward.hoon` carries only cross-cutting config (currently just `%configure`); each module's protocol lives in its own file. - -Modules: - -| Module | Purpose | -|-----------|------------------------------------------------------------------------| -| `lens` | Per-run bot introspection (folded in from the former `%context-lens`). | -| `gateway` | Harness liveness tracking + offline DM auto-replies. | - -The app helper core keeps each module's logic in its own sub-core: `le-core` for lens, `ga-core` for gateway. Adding a new module means a new `sur/steward/.hoon`, its own mark family, and a dispatch arm in the app — existing modules and marks are untouched. - -## state model - -State is a single `state-0`, defined in the app file (the agent is greenfield, so there is no migration — an unreadable state just resets to bunt). Cross-cutting config is top level; each module owns its own slice, typed from its own sur file: - -``` -state-0 - owner (unit ship) shared config: bot sends runs to it / its DMs are watched; ~ = inert - bots (set ship) owner-side trusted bots: who may send lens %entry pokes cross-ship - lens state:v1:lens stored lens run records (owner role) - gateway state:v1:gateway harness liveness + auto-reply bookkeeping -``` - -`owner` is shared: the lens module sends runs to it, and the gateway module treats its DMs as owner activity worth auto-replying to. `bots` is the owner-side allowlist of ships permitted to fan lens runs in (see the `%entry` gate below); managed via the core `%trust-bot`/`%untrust-bot` pokes. - -`run` (in `sur/steward/lens.hoon`): - -``` -complete ? whether a finalized (final=&) record has been received for this id -received @da when the latest poke for this id arrived -payload json the run record, stored as typed JSON -``` - -The lens payload is stored as a typed `$json` value (`enjs:format`/`dejs:format` on the wire). The gateway enforces size caps and truncation before poking; the ship relays and stores the parsed JSON without interpreting its contents, and re-serializes it on read. (Storing typed `$json` is fine — an earlier worry that embedding `$json` in a mark sample made ford's tube checks diverge turned out to be a mark-arm/type **shadowing** bug, not a property of `$json`. The mark captures the real type in an outer core, `=> |% +$ jsn json --`, and uses `same` as the json fist so the `++json` grow/grab arms don't shadow the `$json` type.) - -## module: lens - -Makes a bot's run records — trigger, tool calls, timings, output — durable on the owner's ship and reachable from any client (including mobile), without the client ever talking to the gateway. - -One agent, two roles; the same code runs on every ship, and the role is determined by **who poked it** (the `%steward-lens-action-1` ownership gate has already vetted the source — see below): - -- **bot ship role** (`src == our`): the local gateway pokes `%steward-lens-action-1` with a run record. `le-poke-action` sends it to the configured `owner` as a `%steward-lens-action-1` poke. Ames retries until ack, so owner-ship downtime or gateway restarts don't drop finalized runs once poked. -- **owner ship role** (`src` is a trusted bot — in our `bots` set): a bot sent us its run. `le-poke-action` stores it keyed `[bot=src id]`, gives a fact on `/v1/lens`, and answers scries for clients. - -A self-owned bot (`owner` equal to `our`) is stored directly during send with no network hop. - -The lens action is a tagged union of three shapes: - -- **`%entry`** `[%entry id=@t payload=json final=?]` — a run record from the gateway. `final=&` marks the run complete; `final=|` is an in-progress milestone that upserts a partial record. A finalized run is never demoted back to partial by a late `final=|` (the late partial is dropped). Oversized payloads (jammed size over 512KB) are dropped to bound loom usage. -- **`%retry`** `[%retry bot=ship id=@t]` — an owner-initiated request to re-dispatch a failed/aborted run. The symmetric case to `%entry` (bot → owner): retry flows owner → bot. If `bot == our`, the agent emits a `%retry-requested` fact on `/v1/lens` for the local gateway to act on; if `bot != our`, the owner's steward relays a cross-ship `%retry` poke to that bot's steward, which then emits the fact for its own gateway. Retry never mutates stored state — the gateway creates a fresh run and pokes it back via `%entry`. -- **`%configure`** `[%configure max-runs-per-bot=@ud]` — set the per-ship retention cap (local only); applied to every bot immediately. - -### retention - -Count-bounded only — lens runs are durable memory, not transient logs, so there is **no time-based expiry**. Each bot keeps at most `max-runs-per-bot` records (default 3,000, seeded at install; changed via `%configure`). When a bot exceeds the cap, the oldest by `received` are dropped. Enforced on every insert (bounds that bot's tail) and on `%configure` (re-applies a new cap to every bot). No prune timer. - -## module: gateway - -Tracks the liveness of an external harness process and sends offline DM auto-replies on the bot's behalf while it's down — the part the harness can't do for itself, since no harness code runs during downtime. Ported from the former standalone `%gateway-status` agent (now a thin proxy; see `backend/desk/app/gateway-status.md`). - -The harness reports its lifecycle via the gateway action: `%gateway-start` (with a `boot-id` and a lease expiry), periodic `%gateway-heartbeat`s that extend the lease, and a graceful `%gateway-stop`. A behn timer on `/gateway/lease-check` fires at the lease expiry; if no heartbeat renewed it, the gateway is marked `%down`. `boot-id` matching distinguishes graceful-stop recovery from crash recovery exactly as in the original agent (stop clears `boot-id` so late heartbeats can't revive it; crash/expiry retains it so a delayed heartbeat can). - -While the gateway is not live, a DM from the configured `owner` triggers a canned offline auto-reply to that ship (subject to a dedupe on the triggering message key and a `reply-cooldown`). Around stop/start transitions, a "restarting" / "back online" notice is sent to the owner if they messaged within `active-window`. Inbound owner DMs are observed via a subscription to `%activity /v5`. - -`owner` is the shared top-level `(unit ship)`, set via the core `%configure`. This matches `%gateway-status`'s original single-owner model. The gateway action's own `%configure` carries only timing (`active-window`, `reply-cooldown`); the owner is set once at the core level. - -## poke surface - -Three inbound marks, each ownership-gated to admit exactly the right source. - -### `%steward-action-1` (core config) — `src == our` - -```json -{ "configure": { "owner": "~sampel-palnet" } } -``` - -``` -[%configure owner=ship] top-level: set the shared owner -[%trust-bot ship=ship] add a ship to the trusted-bots set -[%untrust-bot ship=ship] remove a ship from the trusted-bots set -``` - -`%trust-bot`/`%untrust-bot` manage the owner-side `bots` allowlist that gates lens `%entry` fan-in. Trust is explicit and ship-class-agnostic — a bot may be a planet, moon, comet, star, or galaxy, and moon sponsorship is **not** an auto-trust. - -### `%steward-lens-action-1` (lens) - -Auth is **per-variant**, since each shape expects a different `src`: - -- `%entry` — accepted iff `src` is `our`, or `src` is in the owner-side trusted-bots set (`bots`, granted via the core `%trust-bot` poke). Ship-class-agnostic: a trusted bot may be a planet, moon, comet, etc. Moon sponsorship is **not** an auto-trust — even a moon the owner sponsors must be explicitly `%trust-bot`'d. This is the one shape a trusted remote ship may submit (its own runs, stored keyed by `src`). -- `%retry` — accepted iff `src` is `our` (a local client, or an owner-side relay forwarding to its own bot when `bot == our`) or the configured `owner` (relaying a retry to its bot moon). -- `%configure` — `src == our` only. - -```json -{ "entry": { "id": "", "payload": { ... run record ... }, "final": true } } -{ "retry": { "bot": "~sampel-palnet", "id": "" } } -{ "configure": { "max-runs-per-bot": 10000 } } -``` - -``` -[%entry id=@t payload=json final=?] a lens run milestone (final=& finalizes) -[%retry bot=ship id=@t] owner-initiated re-dispatch request -[%configure max-runs-per-bot=@ud] set the per-bot retention cap -``` - -### `%steward-gateway-action-1` (gateway) — `src == our` - -Only the local gateway drives liveness, so this requires `src == our`. - -``` -[%configure active-window=@dr reply-cooldown=@dr] set notice/cooldown timing (owner set separately) -[%gateway-start boot-id=@t lease-until=@da] a gateway instance started -[%gateway-heartbeat boot-id=@t lease-until=@da] extend the lease (boot-id must match) -[%gateway-stop boot-id=@t reason=@t] graceful stop (boot-id must match) -``` - -## subscription surface - -- `/v1/lens` (local only, `?> =(src our)`): `%steward-lens-update-1` facts (`update:v1:lens`, a tagged union) — `%entry` (a stored run, one per insert; the owner-side client reads these) and `%retry-requested` (emitted on the bot ship for its local gateway to re-dispatch). No initial backfill fact — clients scry `/x/v1/lens/recent` for backfill. -- `/v1/gateway` (local only): `%steward-gateway-update-1` facts (`update:v1:gateway`) — `%status` (on lifecycle transitions, plus an initial fact on subscribe), `%owner-activity`, and `%auto-reply`. - -## scry surface - -All lens scries return the `%steward-lens-update-1` mark so the HTTP client reads them as JSON. - -- `/x/v1/lens/recent` → `[%recent entries]` — newest 50 runs across all bots, for backfill. Grows to `{ "recent": [ entry, … ] }` (a JSON array of entry objects). -- `/x/v1/lens/recent/[count]` → `[%recent entries]` — newest `count` runs. -- `/x/v1/lens/since/[da]` → `[%recent entries]` — every run with `received >= da`, newest first; paginate history by passing the oldest `received` from the last page. -- `/x/v1/lens/run/[ship]/[id]` → `[%entry entry]`, or empty (`[~ ~]`) when absent. -- `/x/v1/gateway/status` → `%noun` `[status:v1:gateway (unit @da)]` — current liveness and lease expiry. -- `/x/v1/gateway/owner-activity` → `%noun` `@da` — timestamp of the most recent owner DM. - -`entry` is `[bot=ship id=@t run]`. The `%entry` update grows to JSON for Eyre, embedding the stored payload directly: - -```json -{ "entry": { "bot": "~zod", "id": "...", "complete": true, "received": "~2026.6.10..12.00.00..0000", "payload": { ... run record ... } } } -``` - -## lifecycle and invariants - -- `on-init` subscribes to `%activity /v5` for the gateway module and seeds the default lens retention cap. There is no prune timer (retention is count-only, enforced on insert/configure). -- `on-load` accepts the single `state-0`, else resets to bunt (re-seeding the cap and re-subscribing to `%activity`). The agent is greenfield/unreleased, so there are no migration arms — versioned state + migrations get added back when something actually ships. -- Wires: lens send on `/lens/send/[owner-p]/[id-t]`, lens retry relay on `/lens/retry/[bot-p]/[id-t]`, the gateway lease timer on `/gateway/lease-check`, gateway auto-reply/notice DM sends on `/gateway/dm/send`. The `%activity` subscription is re-watched on `%kick`. Poke/DM nacks are logged and ignored (Ames retries). -- `on-watch` and `on-peek` assert `=(src our)` — no cross-ship subscriptions or foreign scries. Only the lens poke is ownership-gated (to admit a bot's runs). - -## integration notes - -- The gateway (openclaw-tlon / hermes) pokes core `%configure` on monitor activation and `%steward-lens-action-1` run milestones from its run event stream. Lens recording is config-gated on the gateway side (`channels.tlon.contextLens`). -- Clients store runs locally, subscribe to `/v1/lens` for live updates, and scry on cache miss. The channel post pointer blob carries `botShip` so the client knows which `[bot id]` key to look up. -- The gateway's HTTP/SSE routes remain an optional desktop enhancement for fine-grained live streaming; `%steward`'s lens module is the durable source of truth. -- The `%steward-lens-*` marks replace the former `%context-lens-*` marks. There is no separate cross-ship `signal` mark — sending reuses `%steward-lens-action-1`, gated by ownership. diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/.openspec.yaml b/openspec/changes/mirror-openclaw-automations-to-steward/.openspec.yaml new file mode 100644 index 0000000000..84cfc12459 --- /dev/null +++ b/openspec/changes/mirror-openclaw-automations-to-steward/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-06 diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/design.md b/openspec/changes/mirror-openclaw-automations-to-steward/design.md new file mode 100644 index 0000000000..76479659d8 --- /dev/null +++ b/openspec/changes/mirror-openclaw-automations-to-steward/design.md @@ -0,0 +1,237 @@ +## Context + +See `proposal.md` for motivation and +`specs/steward-automation-projection/spec.md` for required behavior. + +`%steward` is a released Gall agent whose persisted state already +contains core configuration, lens state, and gateway state. Adding +automation therefore requires a compatible state migration rather than +an in-place shape change or reset. + +The pinned OpenClaw version provides `gateway_start`, `cron_changed`, +`gateway_stop`, and access to the current cron service through +`getCron()`, but not the newer `cron_reconciled` hook. Gateway startup +can also precede cron-service readiness. These constraints make the +mirror best-effort: OpenClaw remains authoritative, while `%steward` +stores the latest complete snapshot the harness successfully +submitted. + +## Goals / Non-Goals + +**Goals:** + +- Establish a repairable projection boundary between the OpenClaw + harness and `%steward`. +- Prevent concurrent reconciliation from leaving an older snapshot + as the final stored result. +- Preserve all released `%steward` state while adding automation + storage. +- Keep the automation protocol independently evolvable from existing + Steward modules. + +**Non-Goals:** + +- Making `%steward` authoritative for scheduling or execution. +- Providing freshness guarantees while OpenClaw is unavailable. +- Extending the OpenClaw compatibility floor. +- Designing cross-ship synchronization or task mutation APIs. + +## Decisions + +### 1. Commit complete snapshots atomically with `%project` + +The OpenClaw harness will submit the complete current task-definition +set through `%project` as a list of identified task entries. +`%steward` will validate the complete input, separate each ID from its +task definition, and commit the resulting task map in one state +transition. Task IDs provide stable map keys, and a successful +`%project` with an empty task list represents no configured tasks. + +The `%project` name describes committing the harness's complete +external projection rather than mutating individual tasks. This makes +startup and later cron events use the same repair path, makes repeated +submissions idempotent, and removes tasks that disappeared while the +integration was unavailable. + +**Alternative considered:** Applying `cron_changed` payloads as +additions, updates, or removals would use less data, but those events +are not an ordered, durable delta log and cannot repair missed events. + +### 2. Use the current OpenClaw hook surface without upgrading + +Gateway startup and every cron-change event will trigger a complete +read of the current cron task set, including disabled tasks. The +harness will obtain that set through the cron access available in the +pinned OpenClaw version. Gateway stop ends active reconciliation +without clearing the durable Steward snapshot. + +Unavailable cron access is treated as temporary failure, not as an +empty task list. This distinction prevents scheduler startup timing or +disablement from accidentally erasing the last successful projection. + +**Alternative considered:** Upgrading to a version with +`cron_reconciled` would provide a stronger lifecycle boundary, but +upgrading OpenClaw is outside this change. + +### 3. Serialize and coalesce reconciliation + +Only one complete read-and-submit reconciliation will be active at a +time. Triggers received while it is active will be coalesced into one +follow-up reconciliation. Failed reads or submissions will be retried +while the gateway remains active. + +Serialization prevents overlapping submissions from completing out of +order. Coalescing preserves repair behavior without issuing one full +read for every event in a burst. The detailed worker lifecycle, retry +policy, and race tests belong in the implementation tasks rather than +this design. + +**Alternative considered:** Independent work per event is simpler +locally, but an older request could finish last and overwrite a newer +snapshot. + +### 4. Store typed task definitions and exclude execution state + +The automation model will represent each submitted OpenClaw job as an +identified task entry pairing its ID with a typed task definition. +`%steward` will store `(map @t task)`, using the ID only as the map +key and not duplicating it inside the stored task value. The task +definition will cover the supported fields and schedule variants +identified by the capability spec. Optional values will remain +optional, and boundary conversion will preserve OpenClaw's JSON +shapes. Cron job state and execution events will not be part of the +stored model. + +A typed model gives `%steward` a versioned, validated representation +and prevents execution tracking from entering scope implicitly. + +Testing will preserve the projection boundary. TypeScript +normalization tests will consume JSON fixtures captured from actual +`getCron().list()` traces, including fields intentionally omitted from +the projection. Once the production JSON marks exist, Hoon JSON +conversion tests will parse normalized `%project` JSON through the +production `dejs` path and serialize task maps through the production +`enjs` path. Hand-constructed tests will remain only for focused +primitive conversion boundaries. + +**Alternatives considered:** Storing the normalized projection as +opaque JSON would reduce backend conversion work and schema coupling, +but would delegate structural validation to the harness. Storing raw +OpenClaw job JSON would additionally persist fields outside this +change. + +### 5. Use separate action and task-map marks + +The automation module will use an independently versioned `%project` +action mark for complete projection commits and a dedicated task-map +mark for the JSON scry. The scry mark will directly accept +`(map @t task)` and grow it to `{ "tasks": { "": , ... } }`; +the JSON object key will be the sole serialized task ID. Automation +has no subscription surface or heterogeneous scry results that would +justify a tagged `$update` union. Keeping the inbound action and +outbound task-map representations separate allows either side to +evolve independently. + +The OpenClaw harness is the submitting actor. `%steward` does not +authenticate a distinct harness identity in this increment; it +authorizes `%project` pokes through the existing local Gall source +boundary and rejects foreign poke sources. Dotket scries execute +locally against the current agent state and have no foreign source to +authorize. + +**Alternative considered:** Reusing one mark for both directions would +conflate action parsing with scry serialization. A tagged scry-result +union would duplicate information already carried by the scry path and +dedicated task-map mark. + +### 6. Add an explicit migration from the released Steward state + +A new persisted state version will add the automation slice. Loading +the released version will copy core configuration, trusted bots, lens +state, and gateway state unchanged, then initialize automation as +empty. Fresh installations will start directly with the new state +version. + +Migration failure must be visible rather than falling back to default +state. This protects existing deployed data from accidental reset. + +**Alternative considered:** Extending the released state shape in +place or resetting state on decode failure risks making deployed state +unloadable or silently losing existing data. + +### 7. Keep projection and telemetry failures isolated + +The existing cron telemetry observer and the new Steward projection +may share access to the current cron service, but neither path will +depend on the other's success. Projection delivery failures must not +suppress telemetry, and telemetry failures must not stop later +projection attempts. + +This retains the current operational observer while keeping the new +durable projection independently testable and recoverable. + +**Alternative considered:** Combining both behaviors into one +operation would reduce calls but would couple unrelated failure +handling and broaden the impact of either subsystem failing. + +### 8. Fail closed when multiple Tlon accounts can run + +The v1 adapter reads one process-global monitor connection slot and +cannot identify which account published it. Projection will therefore +run only when configuration contains exactly one enabled, fully +configured Tlon account. Zero runnable accounts leave projection +inactive. More than one runnable account stops the active +reconciliation epoch, ignores later change triggers, and preserves +each ship's last accepted Steward snapshot rather than selecting the +last monitor to publish. + +Disabled or incomplete account entries do not make the active +connection ambiguous and therefore do not disable projection. The +eligibility check reloads current OpenClaw configuration for every +gateway-start and cron-change hook, so one-to-zero and one-to-many +configuration transitions fail closed on the next projection trigger +even when no replacement account monitor starts. + +**Alternative considered:** Indexing monitor connections by account +and fanning every complete snapshot out to every bot ship would provide +full multi-account support, but requires per-account delivery, +acknowledgement, retry, and shutdown state and is outside this v1 +projection. + +## Risks / Trade-offs + +- **[Startup can precede cron-service readiness]** → Retry complete + reconciliation while preserving the last successful snapshot. +- **[Missed events or process crashes can leave the mirror stale]** + → Reconcile from a complete list on the next gateway startup or + observed cron change, and document best-effort freshness. +- **[Execution-related events can cause unnecessary full reads]** → + Coalesce triggers; prefer a simple repair path until operational + evidence requires filtering. +- **[Future OpenClaw task variants may not fit the v1 action and + task types]** → Reject unsupported `%project` submissions without + changing the last known-good projection, then add a later protocol + version. +- **[Large snapshots increase poke and loom usage]** → Test + realistic payloads and defer explicit limits until usage data + justifies them. +- **[Migration defects could damage released state]** → Test + migration with populated values in every existing state slice and + fail rather than reset on decode errors. + +## Migration Plan + +1. During development, introduce the new current state shape with a + deliberately failing released-state migration stub, nuke only the + disposable development agent state, and initialize the new state + fresh. +2. Implement and exercise `%project`, storage, marks, and the scry + against that fresh state. +3. After the state and API shapes have been validated in practice, + implement and test migration from the released state. +4. Deploy the completed migration, automation types and marks, + storage, and scry before or together with the harness projection. +5. Enable the harness projection; its first successful startup + reconciliation populates the initially empty automation slice. +6. To roll back the harness integration, disable projection and leave + the last Steward snapshot intact; OpenClaw remains authoritative. diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md b/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md new file mode 100644 index 0000000000..5c8e3ab69d --- /dev/null +++ b/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md @@ -0,0 +1,69 @@ +## Why + +OpenClaw currently keeps automation definitions inside the external +harness, so the bot ship has no durable ship-native view of its +configured tasks. Mirroring the authoritative OpenClaw task set +introduces an automation module to the released `%steward` agent while +leaving owner access, manipulation, and additional harnesses for later +work. + +## What Changes + +- Add an automation module to the released `%steward` agent that + persistently stores the bot's current OpenClaw cron task + definitions. +- Migrate existing deployed `%steward` state to initialize the + automation module without losing core, lens, or gateway state. +- Add an independently versioned `%project` automation action that + accepts only the local Gall poke source and atomically commits a + complete task projection as the current stored snapshot. +- After OpenClaw `gateway_start`, have the OpenClaw harness read all + jobs, including disabled jobs, and submit the complete snapshot + through `%project`. +- Treat `cron_changed` events as reconciliation triggers: have the + harness reread and submit the complete current job list through + `%project` rather than applying event payloads as deltas. +- Serialize and coalesce reconciliation so snapshots cannot overtake + one another, and retry transient failures while the gateway + remains active. +- Enable the v1 projection only when exactly one runnable Tlon account + is configured; fail closed rather than selecting an arbitrary ship + from the process-global monitor connection slot. +- Keep the current OpenClaw version and document that, without + `cron_reconciled`, this is a best-effort mirror repaired on + gateway startup and subsequent cron changes rather than an + authoritative external projection. +- Add a `%steward` dotket scry, which executes locally against the + current agent state without caller-source authorization, that + returns the complete stored task projection as a JSON object keyed + by task ID without duplicating IDs inside task values. +- Exclude execution tracking, run history, owner-ship replication, + owner-client integration, cron manipulation, and non-OpenClaw + harnesses from this change. + +## Capabilities + +### New Capabilities + +- `steward-automation-projection`: Durable, best-effort mirroring of + complete OpenClaw cron task snapshots into the bot's local + `%steward`, with a JSON scry read surface. + +### Modified Capabilities + +None. + +## Impact + +- Backend: a versioned `%steward` state migration, automation + dispatch, new automation action and task-map marks, JSON + conversion, scry handling, and Hoon tests. +- OpenClaw plugin: `gateway_start`/`cron_changed` full-snapshot + reconciliation, serialized retry behavior, snapshot encoding and + poking, and TypeScript tests. +- Documentation: `%steward` automation module state, poke interface, + and scry interface. +- Compatibility: existing deployed `%steward` state must migrate + without losing core, lens, or gateway data. Existing marks remain + unchanged, and OpenClaw remains the sole source of truth for + scheduling and execution. diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/specs/steward-automation-projection/spec.md b/openspec/changes/mirror-openclaw-automations-to-steward/specs/steward-automation-projection/spec.md new file mode 100644 index 0000000000..2a2fcf8902 --- /dev/null +++ b/openspec/changes/mirror-openclaw-automations-to-steward/specs/steward-automation-projection/spec.md @@ -0,0 +1,282 @@ +## Purpose + +Provide the bot ship with a durable, locally readable, best-effort +mirror of complete OpenClaw cron task definitions while OpenClaw +remains authoritative for scheduling and execution. + +## ADDED Requirements + +### Requirement: Gateway startup triggers a complete task read + +After `gateway_start`, the OpenClaw harness SHALL read the complete +current task-definition set, including disabled tasks, and submit it +to the bot's local `%steward` through `%project`. The submitted +definitions SHALL exclude cron job `state` and execution events. + +#### Scenario: Task definitions are available at startup + +- **WHEN** OpenClaw emits `gateway_start` and the current + task-definition set is available +- **THEN** the harness reads all tasks with disabled tasks included + and submits the complete definition set to `%steward` through + `%project` + +#### Scenario: Task definitions are not yet available at startup + +- **WHEN** OpenClaw emits `gateway_start` but the current + task-definition set is not yet available +- **THEN** the harness keeps the existing `%steward` projection and + retries the complete read while the gateway remains active + +#### Scenario: Startup list is empty + +- **WHEN** the complete startup read succeeds and returns no tasks +- **THEN** the integration projects an empty task set to `%steward` + +### Requirement: Cron changes trigger complete rereads + +The OpenClaw harness SHALL treat every `cron_changed` event as a +reconciliation trigger rather than applying the event payload as a +task delta. Each event SHALL request reconciliation from a complete +read, subject to the serialization and coalescing requirements below. + +#### Scenario: Cron change occurs + +- **WHEN** OpenClaw emits `cron_changed` +- **THEN** the harness reads the complete current task set, + including disabled tasks, and submits it through `%project` rather + than applying the event payload directly + +#### Scenario: Execution-related cron event occurs + +- **WHEN** a `cron_changed` event describes execution activity + rather than a task-definition change +- **THEN** the harness handles it through the same complete-reread + reconciliation path and omits execution-event fields and cron job + state from the submitted definitions + +### Requirement: Reconciliation is serialized and coalesced + +The OpenClaw harness SHALL allow at most one complete read-and-submit +operation to be outstanding. Triggers received while that operation is +outstanding SHALL cause one additional complete reconciliation after +the outstanding operation settles, and multiple such triggers SHALL be +coalesced into that one follow-up operation. + +#### Scenario: Trigger arrives during listing + +- **WHEN** one or more triggers arrive while the integration is + listing tasks +- **THEN** no concurrent list-and-project operation starts and a + follow-up complete reconciliation runs afterward + +#### Scenario: Trigger arrives during Steward delivery + +- **WHEN** one or more triggers arrive while a complete snapshot is + being delivered to `%steward` +- **THEN** the current delivery finishes before one follow-up + complete reconciliation begins + +#### Scenario: Many triggers arrive while busy + +- **WHEN** multiple triggers arrive during one outstanding + reconciliation +- **THEN** the harness coalesces them into one subsequent complete + read and replacement + +### Requirement: Failed reconciliation is retried + +A failed complete read or `%project` delivery SHALL NOT clear the last +successfully stored projection. The OpenClaw harness SHALL retry the +complete reconciliation while the gateway remains active without +starting a concurrent reconciliation. + +#### Scenario: Complete read fails + +- **WHEN** reading the complete task-definition set fails +- **THEN** the harness retains the last successful `%steward` + projection and retries the complete reconciliation + +#### Scenario: Steward delivery fails + +- **WHEN** `%steward` does not accept a `%project` submission +- **THEN** the harness retains the last successful projection and + retries without starting a concurrent delivery + +#### Scenario: Gateway stops + +- **WHEN** OpenClaw emits `gateway_stop` +- **THEN** the integration stops starting retries and new + reconciliations while preserving the last successfully stored + `%steward` projection + +### Requirement: Projection requires one runnable Tlon account + +The OpenClaw harness SHALL enable Steward automation projection only +when exactly one Tlon account is both enabled and fully configured. +When zero or multiple accounts are runnable, the harness SHALL NOT +select a connection from the process-global monitor slot or submit a +projection. Becoming ineligible SHALL stop active reconciliation +without clearing any previously stored Steward projection. + +#### Scenario: Exactly one account is runnable + +- **WHEN** exactly one enabled and fully configured Tlon account exists +- **THEN** gateway startup and cron changes use that account's local + Steward connection for projection + +#### Scenario: Multiple accounts are runnable + +- **WHEN** more than one enabled and fully configured Tlon account + exists when a gateway-start or cron-change trigger is handled +- **THEN** the harness does not read or submit an automation projection + and does not choose whichever monitor last published its connection + +#### Scenario: Additional account is disabled or incomplete + +- **WHEN** configuration contains additional Tlon account entries but + exactly one account is enabled and fully configured +- **THEN** the harness treats the sole runnable account as eligible for + projection + +#### Scenario: Configuration becomes ambiguous + +- **WHEN** projection is active and a later trigger observes multiple + runnable Tlon accounts +- **THEN** the harness stops the active reconciliation epoch, starts no + new projection work, and preserves the last stored Steward snapshot + +### Requirement: Mirror freshness is best-effort + +The `%steward` automation state SHALL represent the latest complete +OpenClaw task list that the integration successfully read and +delivered. The system SHALL NOT claim that this mirror is +authoritative or current while OpenClaw is offline or before a +successful startup/change-triggered reconciliation. + +#### Scenario: Change is missed while the integration is offline + +- **WHEN** OpenClaw task state changes without a corresponding + successful reconciliation +- **THEN** `%steward` retains its last successful snapshot until a + later gateway startup or cron-change trigger repairs it + +#### Scenario: Reconciliation succeeds after stale period + +- **WHEN** a later complete reconciliation succeeds after the mirror + has been stale +- **THEN** `%steward` atomically replaces the stale task set with + the newly read complete set + +### Requirement: Steward commits task projections atomically + +The local `%steward` `%project` automation action SHALL accept a +complete list of task definitions from the local OpenClaw harness only +when the poke's Gall source is the local ship. It SHALL atomically +commit the submitted complete projection as the current task set, +keyed by OpenClaw task ID. Repeating an equivalent `%project` +submission SHALL leave the same stored result. + +#### Scenario: Complete snapshot is accepted + +- **WHEN** the local OpenClaw harness submits a valid `%project` + action through the local ship source +- **THEN** `%steward` stores exactly those task definitions and + removes every task absent from the snapshot + +#### Scenario: Empty snapshot is accepted + +- **WHEN** the local OpenClaw harness submits `%project` with an + empty task list through the local ship source +- **THEN** `%steward` stores no automation tasks + +#### Scenario: Equivalent snapshot is repeated + +- **WHEN** the local OpenClaw harness submits the same logical + `%project` action more than once through the local ship source +- **THEN** `%steward` retains the same task projection without + duplicate records + +#### Scenario: Foreign ship submits a snapshot + +- **WHEN** a source other than the local ship submits a `%project` + action +- **THEN** `%steward` rejects it without changing stored tasks + +### Requirement: Task definitions preserve supported OpenClaw fields + +The projection SHALL preserve each OpenClaw task ID as the key of its +stored task map. Each stored task value SHALL preserve the supplied +definition fields for agent ownership, display metadata, enabled +state, schedule, session target, wake mode, payload, and +creation/update timestamps when those fields are present. The task ID +SHALL NOT be duplicated inside the stored task value. The projection +SHALL support `cron`, `at`, and `every` schedule variants and SHALL +not store the cron job `state` object. + +#### Scenario: Fully populated task is mirrored + +- **WHEN** a complete snapshot contains a task with supported + optional definition fields +- **THEN** the stored projection uses the task ID as its map key and + the stored task value and JSON representation preserve the + supported definition fields and their absence/presence semantics + without duplicating the ID + +#### Scenario: Optional fields are absent + +- **WHEN** an OpenClaw task omits optional definition fields +- **THEN** `%steward` accepts and stores the task without inventing + values for those fields + +### Requirement: Released Steward state migrates safely + +Upgrading the released `%steward` agent SHALL preserve its existing +core configuration, trusted-bot set, lens state, and gateway state +while initializing the new automation state with an empty task map. +Recognizable deployed state SHALL NOT be silently reset when migration +fails. + +#### Scenario: Existing Steward state is upgraded + +- **WHEN** `%steward` loads a valid state from the released version +- **THEN** it migrates all existing core, lens, and gateway values + unchanged and initializes automation tasks as empty + +#### Scenario: Fresh Steward installation starts + +- **WHEN** `%steward` initializes without prior state +- **THEN** it creates the current state shape with an empty + automation task map + +#### Scenario: Deployed state cannot be migrated + +- **WHEN** `%steward` recognizes a deployed state version but cannot + migrate it safely +- **THEN** loading fails visibly rather than silently replacing + existing data with default state + +### Requirement: JSON task scry + +`%steward` SHALL expose a scry at `/x/v1/automation/tasks` that +returns the complete currently stored task projection as JSON. The +dotket scry SHALL execute locally against the current agent state and +SHALL NOT authorize a caller source. The response SHALL contain a +`tasks` object keyed by OpenClaw task ID. Each value SHALL use the +supported OpenClaw field names and JSON value shapes while omitting +the task ID and cron job state; the property name SHALL be the sole +serialized task ID. + +#### Scenario: Stored tasks are read + +- **WHEN** a client scries `/x/v1/automation/tasks` after a snapshot + has been accepted +- **THEN** it receives a JSON object whose `tasks` object contains + one property per stored task ID and whose values do not duplicate + those IDs + +#### Scenario: No tasks are stored + +- **WHEN** a client scries `/x/v1/automation/tasks` while the + projection is empty +- **THEN** it receives `{ "tasks": {} }` diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md new file mode 100644 index 0000000000..97d0703dab --- /dev/null +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -0,0 +1,132 @@ +## 1. Automation Types and Conversions + +- [x] 1.1 Finalize the v1 automation types for identified inbound + tasks, ID-free stored task definitions, automation state keyed + by task ID, complete `%project` actions, and task-map scry + results, excluding cron job state and execution events. +- [x] 1.2 Complete the Hoon millisecond, date, and duration + conversions required by supported schedule and timestamp + fields. +- [x] 1.3 Extend conversion tests with boundary, round-trip, + optional-field, and supported-schedule cases. + +## 2. Steward State, Storage, and JSON API + +- [x] 2.1 Introduce the new current Steward state version with an + empty automation slice for fresh initialization. Stub the + released-state migration to fail visibly, and use a nuked + disposable development agent while validating the new state + shape. +- [x] 2.2 Implement atomic `%project` commits keyed by task ID, + including empty and repeated projections, removal by omission, + duplicate-ID rejection, and unchanged state after invalid + input. +- [x] 2.3 Enforce the local Gall source boundary for `%project` and + reject foreign sources without changing state. +- [x] 2.4 Add the versioned automation action mark and dedicated + task-map scry mark with validated `%project` JSON/noun + conversion, `{ "tasks": { "": , ... } }` JSON + serialization, no duplicated IDs in task values, and no cron + job state. +- [x] 2.5 Add the local `/x/v1/automation/tasks` scry that returns + the stored task map and `{ "tasks": {} }` for empty state. +- [x] 2.6 Test the fresh-state implementation through the production + marks and scry: populated, empty, repeated, invalid, and + foreign `%project` submissions, supported task fields, + persistence, JSON conversion, and scry behavior. Replace the + hand-constructed type and schedule tests from 1.3 with + realistic normalized `%project` JSON fixtures derived from + captured OpenClaw traces while retaining focused conversion + boundary tests. +- [x] 2.7 After the state shape, storage behavior, marks, and scry + have been validated in practice, implement released-state + migration preserving populated core, trusted-bot, lens, and + gateway values while initializing automation empty. +- [x] 2.8 Add migration tests for populated released state, fresh + initialization, persistence, and visible failure instead of + silent reset. + +## 3. OpenClaw Harness Projection + +- [x] 3.1 Add task normalization that preserves supported definition + fields, omits execution state, and produces complete Steward + `%project` payloads. +- [x] 3.2 Add a local Steward adapter that submits `%project` + through the monitor-published ship connection and requires + successful poke acknowledgement. +- [x] 3.3 Trigger complete reads with disabled tasks included after + `gateway_start` and every `cron_changed` event using the + pinned OpenClaw cron access. +- [x] 3.4 Serialize reconciliation, coalesce triggers received while + busy into one follow-up, and prevent snapshots from overtaking + one another. +- [x] 3.5 Retry unavailable cron reads and failed Steward + submissions while the gateway remains active, preserving the + last successful projection. +- [x] 3.6 Stop new reconciliation and retry activity on + `gateway_stop` without clearing the durable Steward snapshot. +- [x] 3.7 Replace the temporary diagnostic handler with projection + registration while keeping cron telemetry failures isolated. + +## 4. Projection Verification + +- [x] 4.1 Using captured OpenClaw `getCron().list()` trace fixtures, + test normalization of optional fields and all supported + schedules, inclusion of disabled tasks, and omission of cron + job state. +- [x] 4.2 Test startup reconciliation when cron access is ready, + temporarily unavailable, empty, and restored after a stale + period. +- [x] 4.3 Test complete rereads after definition-related and + execution-related `cron_changed` events. +- [x] 4.4 Test serialized delivery, coalesced triggers, trigger + arrival during submission, and the worker-exit race. +- [x] 4.5 Test read failures, submission failures, retry behavior, + acknowledgement failures, and gateway shutdown. +- [x] 4.6 Add ship-level verification that additions, updates, + removals, disabled tasks, and restart reconciliation appear in + the automation JSON scry. + +## 5. Documentation and Validation + +- [x] 5.1 Document the Steward automation state, migration, local + harness `%project` action and atomic projection-commit + semantics, best-effort OpenClaw flow, exclusions, and JSON + scry. +- [x] 5.2 Run the targeted Hoon tests, applicable backend suite, and + desk compilation on the development ship. +- [x] 5.3 Run OpenClaw formatting, linting, type checking, unit + tests, and relevant integration tests against the existing + pinned runtime. +- [x] 5.4 Run strict OpenSpec validation and verify implementation + coverage for every capability scenario. +- [x] 5.5 Normalize all automation Hoon comments to the repository's + lowercase style. +- [x] 5.6 Group automation JSON codecs under conventional `+dejs` + and `+enjs` helper cores. +- [x] 5.7 Remove impossible foreign-source checks from dotket scries + and align tests, specifications, and documentation. +- [x] 5.8 Format Hoon JSON fixture strings as readable triple-quoted + blocks. +- [x] 5.9 Capture a genuine pinned OpenClaw cron-expression job and + use its normalized shape in the focused Hoon codec test. +- [x] 5.10 Rename `$cron-payload` to the clearer `$task-payload` + throughout the automation types and codecs. +- [x] 5.11 Replace the hand-written ISO timestamp parser with Zod's + ISO datetime validation and focused projection tests. +- [x] 5.12 Replace the remaining projection field helpers with + complete Zod task, schedule, and payload schemas. +- [x] 5.13 Make OpenClaw `message` the canonical agent-turn payload + field, retaining `text` only as a compatibility fallback. +- [x] 5.14 Rename TypeScript `Project` terminology to `Projection` + while preserving the `%project` wire action. +- [x] 5.15 Simplify the Steward automation TypeScript without + weakening validation, lifecycle, or race guarantees. +- [x] 5.16 Inline the single-use reconciler deactivation logic into + `stop()`. +- [x] 5.17 Bound hung cron reads and Steward submissions, fence late + list completion, and allow a stopped gateway epoch to be + replaced without waiting for abandoned promises. +- [x] 5.18 Disable v1 Steward automation projection unless exactly one + Tlon account is runnable, with multi-account and configuration + transition coverage. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000000..c4d34acea9 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,32 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours + +# Per-operation guidance (optional) +# Add advisory guidance for how apply and archive work should be conducted. +# This is separate from artifact rules above. +# Example: +# operations: +# apply: +# guidance: +# - Keep test summaries concise +# archive: +# guidance: +# - Summarize the archive outcome before finishing diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index cd005e7b76..6b736d077d 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -92,6 +92,14 @@ Cron observability rides the gateway's `cron_changed` hook: `TlonBot Cron Job Ch The plugin does not enable telemetry automatically just because an API key is present. `enabled: true` is required so open-source installs do not phone home by default. +## Steward automation mirror + +On pinned OpenClaw `2026.5.28`, the plugin keeps a best-effort ship-side mirror of cron definitions in the bot's local `%steward`. `gateway_start` and every `cron_changed` action trigger a complete `getCron().list({ includeDisabled: true })` read. The plugin normalizes supported `cron`, `at`, and `every` schedules (including ISO `at` text to Unix milliseconds) and submits the complete list through `%steward-automation-action-1` as one `%project` poke. + +Reconciliation is serialized and busy-period triggers are coalesced. Unavailable cron access, read failures, missing ship connections, and poke acknowledgement failures retry while the gateway is active. `gateway_stop` cancels retries and guards against a stale post-stop submission, but deliberately leaves the last successful Steward snapshot intact. The same process-lifetime worker is reused across OpenClaw plugin-registration passes. These behaviors repair the mirror after a later successful read; they do not guarantee continuous freshness. + +OpenClaw remains authoritative. The mirror includes disabled task definitions but excludes execution state and events, run history, delivery data, session keys, and runtime-only fields. It provides no task manipulation, owner administration, or subscription API. Local clients can read the latest accepted map from `/x/v1/automation/tasks`; an empty projection is `{ "tasks": {} }`. See the repository's [Steward backend documentation](../../docs/backend/desk/app/steward.md#module-automation) for the stored type, versioned migration, `%project` JSON shape, atomic replacement behavior, exclusions, and scry mark. + ## Approval System The approval system lets you control who can interact with your bot. When `ownerShip` is configured, you'll receive DM notifications for: diff --git a/packages/openclaw/index.ts b/packages/openclaw/index.ts index aca52dc12b..5bf6924f71 100644 --- a/packages/openclaw/index.ts +++ b/packages/openclaw/index.ts @@ -35,6 +35,7 @@ import { isRouteDebugEnabled } from './src/monitor/session-routing.js'; import { handleOwnerListenCommand } from './src/owner-listen-command.js'; import { setTlonRuntime } from './src/runtime.js'; import { getSessionRole } from './src/session-roles.js'; +import { registerStewardAutomationReconciliationHooks } from './src/steward-automation-reconciliation.js'; import { parseTlonTarget } from './src/targets.js'; import { type TlonDiagnosticLogAttributes, @@ -1366,6 +1367,11 @@ export default defineBundledChannelEntry({ } }); + registerStewardAutomationReconciliationHooks(api, { + logger: { warn: (message) => api.logger.warn(message) }, + getConfig: () => api.runtime.config.loadConfig(), + }); + if (shouldInstallTlonDiagnosticSubscriptions(api.registrationMode)) { const unsubscribeDiagnosticEvents = installTelemetryDiagnosticObservers(api); diff --git a/packages/openclaw/src/fixtures/README.md b/packages/openclaw/src/fixtures/README.md new file mode 100644 index 0000000000..5db4dcdcdc --- /dev/null +++ b/packages/openclaw/src/fixtures/README.md @@ -0,0 +1,5 @@ +# OpenClaw fixtures + +`openclaw-2026.5.28-cron-jobs.sanitized.json` contains sanitized job objects captured from successful cron tool results in the pinned OpenClaw `2026.5.28` development container. The `at` and `every` jobs came from session file `c87e8f5e-1a0c-4866-b967-5c3f44311ca7.jsonl`, jobs `0634ad7a-3ba1-4a65-b64a-db04658d8e64` and `f8a8741a-af0f-4cf1-8da9-43faf429cc7b`. The `cron` job came from a live pinned-runtime CLI capture; both `cron add` and `cron get` returned the same job shape. + +Names, IDs, message text, and delivery recipients were replaced. Field presence, schedule and timestamp values, delivery shapes, and runtime state shapes were retained. The fixture contains no tokens, secrets, or session keys. diff --git a/packages/openclaw/src/fixtures/openclaw-2026.5.28-cron-jobs.sanitized.json b/packages/openclaw/src/fixtures/openclaw-2026.5.28-cron-jobs.sanitized.json new file mode 100644 index 0000000000..294c10465d --- /dev/null +++ b/packages/openclaw/src/fixtures/openclaw-2026.5.28-cron-jobs.sanitized.json @@ -0,0 +1,93 @@ +[ + { + "id": "trace-at-1", + "agentId": "dev", + "name": "Captured one-shot reminder", + "enabled": true, + "deleteAfterRun": true, + "createdAtMs": 1785734006665, + "updatedAtMs": 1785734006665, + "schedule": { + "kind": "at", + "at": "2026-08-03T05:18:21.000Z" + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a short reminder." + }, + "delivery": { + "mode": "announce", + "channel": "tlon", + "to": "~sample" + }, + "state": { + "nextRunAtMs": 1785734301000 + } + }, + { + "id": "trace-every-1", + "agentId": "dev", + "name": "Captured interval reminder", + "enabled": true, + "deleteAfterRun": true, + "createdAtMs": 1785735243782, + "updatedAtMs": 1785740230441, + "schedule": { + "kind": "every", + "everyMs": 120000, + "anchorMs": 1785735243782 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a playful reminder." + }, + "delivery": { + "mode": "announce", + "channel": "tlon", + "to": "~sample" + }, + "state": { + "nextRunAtMs": 1785740343800, + "lastRunAtMs": 1785740223800, + "lastRunStatus": "ok", + "lastStatus": "ok", + "lastDurationMs": 6641, + "lastDelivered": true, + "lastDeliveryStatus": "delivered", + "lastFailureNotificationDeliveryStatus": "not-requested", + "consecutiveErrors": 0, + "consecutiveSkipped": 0, + "runningAtMs": 1785743133278 + } + }, + { + "id": "trace-cron-1", + "agentId": "dev", + "name": "Captured weekday reminder", + "description": "Captured cron expression fixture", + "enabled": false, + "createdAtMs": 1786416589889, + "updatedAtMs": 1786416589889, + "schedule": { + "kind": "cron", + "expr": "17 4 * * 1-5", + "tz": "America/New_York", + "staggerMs": 45000 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "Send a weekday reminder." + }, + "delivery": { + "mode": "none", + "channel": "last" + }, + "state": {} + } +] diff --git a/packages/openclaw/src/steward-automation-adapter.test.ts b/packages/openclaw/src/steward-automation-adapter.test.ts new file mode 100644 index 0000000000..6c5d6fc815 --- /dev/null +++ b/packages/openclaw/src/steward-automation-adapter.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + API_CLIENT_PARAMS_SLOT, + type SharedApiClientParams, +} from './gateway-status.js'; +import { sharedSlot } from './shared-state.js'; +import { + StewardAutomationConnectionUnavailableError, + submitStewardAutomationProjection, +} from './steward-automation-adapter.js'; +import type { StewardAutomationProjection } from './steward-automation-projection.js'; + +const paramsSlot = sharedSlot(API_CLIENT_PARAMS_SLOT); + +const projection: StewardAutomationProjection = { + project: { + tasks: [ + { + id: 'job-1', + enabled: false, + payload: { kind: 'agentTurn', message: 'check status' }, + }, + ], + }, +}; + +function paramsWithPoke( + poke: SharedApiClientParams['poke'] +): SharedApiClientParams { + return { + poke, + shipName: 'zod', + shipUrl: 'http://localhost:8080', + }; +} + +describe('submitStewardAutomationProjection', () => { + beforeEach(() => { + paramsSlot.set(null); + }); + + afterEach(() => { + paramsSlot.set(null); + }); + + it('submits the exact automation poke through the published connection', async () => { + const poke = vi.fn().mockResolvedValue(42); + paramsSlot.set(paramsWithPoke(poke)); + + await submitStewardAutomationProjection(projection); + + expect(poke).toHaveBeenCalledOnce(); + expect(poke).toHaveBeenCalledWith({ + app: 'steward', + mark: 'steward-automation-action-1', + json: projection, + }); + }); + + it('fails with a retryable availability error when no connection is published', async () => { + const submission = submitStewardAutomationProjection(projection); + + await expect(submission).rejects.toMatchObject({ + name: 'StewardAutomationConnectionUnavailableError', + retryable: true, + }); + await expect(submission).rejects.toBeInstanceOf( + StewardAutomationConnectionUnavailableError + ); + }); + + it('does not resolve until the poke acknowledgement resolves', async () => { + let acknowledge!: (value: unknown) => void; + const poke = vi.fn( + () => + new Promise((resolve) => { + acknowledge = resolve; + }) + ); + paramsSlot.set(paramsWithPoke(poke)); + let settled = false; + + const submission = submitStewardAutomationProjection(projection).then( + () => { + settled = true; + } + ); + await Promise.resolve(); + + expect(poke).toHaveBeenCalledOnce(); + expect(settled).toBe(false); + + acknowledge(42); + await expect(submission).resolves.toBeUndefined(); + expect(settled).toBe(true); + }); + + it('propagates poke acknowledgement failures unchanged', async () => { + const nack = new Error('poke nack'); + const poke = vi.fn().mockRejectedValue(nack); + paramsSlot.set(paramsWithPoke(poke)); + + await expect(submitStewardAutomationProjection(projection)).rejects.toBe( + nack + ); + }); + + it('looks up the current slot value for every submission', async () => { + const stalePoke = vi.fn().mockResolvedValue(1); + const currentPoke = vi.fn().mockResolvedValue(2); + + paramsSlot.set(paramsWithPoke(stalePoke)); + await submitStewardAutomationProjection(projection); + paramsSlot.set(paramsWithPoke(currentPoke)); + await submitStewardAutomationProjection(projection); + + expect(stalePoke).toHaveBeenCalledOnce(); + expect(currentPoke).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/openclaw/src/steward-automation-adapter.ts b/packages/openclaw/src/steward-automation-adapter.ts new file mode 100644 index 0000000000..8ce9ca6558 --- /dev/null +++ b/packages/openclaw/src/steward-automation-adapter.ts @@ -0,0 +1,38 @@ +import { + API_CLIENT_PARAMS_SLOT, + type SharedApiClientParams, +} from './gateway-status.js'; +import { sharedSlot } from './shared-state.js'; +import type { StewardAutomationProjection } from './steward-automation-projection.js'; + +const apiClientParamsSlot = sharedSlot( + API_CLIENT_PARAMS_SLOT +); + +export class StewardAutomationConnectionUnavailableError extends Error { + readonly retryable = true; + + constructor() { + super( + 'Steward automation projection is temporarily unavailable: ' + + 'the Tlon monitor has not published a ship connection; retry later' + ); + this.name = 'StewardAutomationConnectionUnavailableError'; + } +} + +/** Submit one complete automation projection through the current monitor. */ +export async function submitStewardAutomationProjection( + projection: StewardAutomationProjection +): Promise { + const params = apiClientParamsSlot.get(); + if (!params) { + throw new StewardAutomationConnectionUnavailableError(); + } + + await params.poke({ + app: 'steward', + mark: 'steward-automation-action-1', + json: projection, + }); +} diff --git a/packages/openclaw/src/steward-automation-projection.test.ts b/packages/openclaw/src/steward-automation-projection.test.ts new file mode 100644 index 0000000000..b48bb94b7f --- /dev/null +++ b/packages/openclaw/src/steward-automation-projection.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from 'vitest'; + +import capturedCronJobs from './fixtures/openclaw-2026.5.28-cron-jobs.sanitized.json'; +import { normalizeStewardAutomationProjection } from './steward-automation-projection.js'; + +type HookCronJob = Parameters< + typeof normalizeStewardAutomationProjection +>[0][number]; + +function runtimeJob(value: unknown): HookCronJob { + return value as HookCronJob; +} + +describe('Steward automation projection normalization', () => { + it('normalizes captured at/every/cron jobs with their optional fields', () => { + const result = normalizeStewardAutomationProjection( + capturedCronJobs.map(runtimeJob) + ); + + expect(result).toEqual({ + project: { + tasks: [ + { + id: 'trace-at-1', + agentId: 'dev', + name: 'Captured one-shot reminder', + enabled: true, + schedule: { kind: 'at', at: 1_785_734_301_000 }, + sessionTarget: 'isolated', + wakeMode: 'now', + payload: { + kind: 'agentTurn', + message: 'Send a short reminder.', + }, + createdAtMs: 1_785_734_006_665, + updatedAtMs: 1_785_734_006_665, + }, + { + id: 'trace-every-1', + agentId: 'dev', + name: 'Captured interval reminder', + enabled: true, + schedule: { + kind: 'every', + everyMs: 120_000, + anchorMs: 1_785_735_243_782, + }, + sessionTarget: 'isolated', + wakeMode: 'now', + payload: { + kind: 'agentTurn', + message: 'Send a playful reminder.', + }, + createdAtMs: 1_785_735_243_782, + updatedAtMs: 1_785_740_230_441, + }, + { + id: 'trace-cron-1', + agentId: 'dev', + name: 'Captured weekday reminder', + description: 'Captured cron expression fixture', + enabled: false, + schedule: { + kind: 'cron', + expr: '17 4 * * 1-5', + tz: 'America/New_York', + staggerMs: 45_000, + }, + sessionTarget: 'isolated', + wakeMode: 'now', + payload: { + kind: 'agentTurn', + message: 'Send a weekday reminder.', + }, + createdAtMs: 1_786_416_589_889, + updatedAtMs: 1_786_416_589_889, + }, + ], + }, + }); + expect(result.project.tasks[0]).not.toHaveProperty('description'); + expect(result.project.tasks[1]).not.toHaveProperty('description'); + for (const task of result.project.tasks) { + expect(task).not.toHaveProperty('state'); + expect(task).not.toHaveProperty('delivery'); + expect(task).not.toHaveProperty('deleteAfterRun'); + expect(task).not.toHaveProperty('sessionKey'); + expect(task.payload).not.toHaveProperty('text'); + } + }); + + it('prefers canonical message and preserves false and zero', () => { + const result = normalizeStewardAutomationProjection([ + runtimeJob({ + id: 'disabled-zero', + enabled: false, + schedule: { + kind: 'cron', + expr: '', + tz: '', + staggerMs: 0, + }, + payload: { + kind: '', + text: 'compatibility text', + message: 'canonical message', + unknown: 'drop me', + }, + createdAtMs: 0, + updatedAtMs: 0, + deleteAfterRun: false, + delivery: { mode: 'announce', to: '~sample' }, + sessionKey: 'drop me', + state: { nextRunAtMs: 1 }, + }), + ]); + + expect(result).toEqual({ + project: { + tasks: [ + { + id: 'disabled-zero', + enabled: false, + schedule: { kind: 'cron', expr: '', tz: '', staggerMs: 0 }, + payload: { kind: '', message: 'canonical message' }, + createdAtMs: 0, + updatedAtMs: 0, + }, + ], + }, + }); + const [task] = result.project.tasks; + expect(task).not.toHaveProperty('state'); + expect(task).not.toHaveProperty('delivery'); + expect(task).not.toHaveProperty('deleteAfterRun'); + expect(task).not.toHaveProperty('sessionKey'); + expect(task.payload).not.toHaveProperty('text'); + }); + + it('uses compatibility text when canonical message is absent', () => { + expect( + normalizeStewardAutomationProjection([ + runtimeJob({ + id: 'compatibility-text', + payload: { kind: 'agentTurn', text: 'fallback message' }, + }), + ]).project.tasks[0]?.payload + ).toEqual({ kind: 'agentTurn', message: 'fallback message' }); + }); + + it('omits explicitly undefined task and schedule fields', () => { + const tasks = normalizeStewardAutomationProjection([ + runtimeJob({ + id: 'undefined-task-fields', + agentId: undefined, + name: '', + description: undefined, + enabled: false, + schedule: { + kind: 'cron', + expr: undefined, + tz: '', + staggerMs: 0, + }, + sessionTarget: undefined, + wakeMode: undefined, + payload: undefined, + createdAtMs: undefined, + updatedAtMs: 0, + }), + runtimeJob({ + id: 'undefined-at-field', + schedule: { kind: 'at', at: undefined }, + }), + runtimeJob({ + id: 'undefined-every-fields', + schedule: { + kind: 'every', + everyMs: undefined, + anchorMs: undefined, + }, + }), + ]).project.tasks; + + expect(tasks).toStrictEqual([ + { + id: 'undefined-task-fields', + name: '', + enabled: false, + schedule: { kind: 'cron', tz: '', staggerMs: 0 }, + updatedAtMs: 0, + }, + { id: 'undefined-at-field', schedule: { kind: 'at' } }, + { id: 'undefined-every-fields', schedule: { kind: 'every' } }, + ]); + for (const field of [ + 'agentId', + 'description', + 'sessionTarget', + 'wakeMode', + 'payload', + 'createdAtMs', + ]) { + expect(tasks[0]).not.toHaveProperty(field); + } + expect(tasks[0]?.schedule).not.toHaveProperty('expr'); + expect(tasks[1]?.schedule).not.toHaveProperty('at'); + expect(tasks[2]?.schedule).not.toHaveProperty('everyMs'); + expect(tasks[2]?.schedule).not.toHaveProperty('anchorMs'); + }); + + it('preserves input order and returns a complete empty projection', () => { + expect( + normalizeStewardAutomationProjection([ + runtimeJob({ id: 'second' }), + runtimeJob({ id: 'first' }), + ]).project.tasks.map(({ id }) => id) + ).toEqual(['second', 'first']); + expect(normalizeStewardAutomationProjection([])).toEqual({ + project: { tasks: [] }, + }); + }); + + it('normalizes an ISO datetime with a timezone offset', () => { + expect( + normalizeStewardAutomationProjection([ + runtimeJob({ + id: 'offset-at', + schedule: { kind: 'at', at: '2026-08-01T14:30:00+02:00' }, + }), + ]).project.tasks[0]?.schedule + ).toEqual({ kind: 'at', at: 1_785_587_400_000 }); + }); + + it.each([ + ['non-object job', null, /Invalid cron job: expected an object/], + [ + 'non-object schedule', + { id: 'bad-schedule', schedule: [] }, + /cron job bad-schedule schedule: expected an object/, + ], + [ + 'non-object payload', + { id: 'bad-payload', payload: null }, + /cron job bad-payload payload: expected an object/, + ], + [ + 'impossible at date', + { id: 'bad-at', schedule: { kind: 'at', at: '2026-02-31T00:00:00Z' } }, + /cron job bad-at schedule\.at: expected an ISO timestamp/, + ], + [ + 'non-number natural number', + { id: 'bad-number-type', createdAtMs: '1' }, + /createdAtMs: expected a number/, + ], + [ + 'fractional natural number', + { id: 'bad-fraction', schedule: { kind: 'every', everyMs: 1.5 } }, + /everyMs: expected a safe integer/, + ], + [ + 'unsafe natural number', + { id: 'bad-unsafe', updatedAtMs: Number.MAX_SAFE_INTEGER + 1 }, + /updatedAtMs: expected a safe integer/, + ], + [ + 'negative natural number', + { id: 'bad-negative', schedule: { kind: 'every', everyMs: -1 } }, + /everyMs: expected a non-negative number/, + ], + [ + 'unsupported schedule', + { id: 'bad-kind', schedule: { kind: 'on-exit' } }, + /unsupported value on-exit/, + ], + [ + 'invalid compatibility payload text', + { id: 'bad-text', payload: { text: 1 } }, + /payload.text: expected a string/, + ], + [ + 'invalid canonical payload message', + { id: 'bad-message', payload: { message: false } }, + /payload.message: expected a string/, + ], + ])('rejects %s', (_name, job, error) => { + expect(() => + normalizeStewardAutomationProjection([runtimeJob(job)]) + ).toThrow(error); + }); + + it('rejects duplicate IDs before producing a projection action', () => { + expect(() => + normalizeStewardAutomationProjection([ + runtimeJob({ id: 'duplicate' }), + runtimeJob({ id: 'duplicate' }), + ]) + ).toThrow('Duplicate cron job id: duplicate'); + }); +}); diff --git a/packages/openclaw/src/steward-automation-projection.ts b/packages/openclaw/src/steward-automation-projection.ts new file mode 100644 index 0000000000..2f52d6cec5 --- /dev/null +++ b/packages/openclaw/src/steward-automation-projection.ts @@ -0,0 +1,191 @@ +import type { PluginHookGatewayCronJob } from 'openclaw/plugin-sdk/types'; +import { z } from 'zod'; + +const EXPECTED_NUMBER = 'expected a number'; +const EXPECTED_SAFE_INTEGER = 'expected a safe integer'; +const EXPECTED_NON_NEGATIVE_NUMBER = 'expected a non-negative number'; +const EXPECTED_ISO_TIMESTAMP = 'expected an ISO timestamp'; + +const ExpectedStringSchema = z.string({ error: 'expected a string' }); +const NaturalNumberSchema = z + .int({ + error: (issue) => + issue.code === 'invalid_type' && issue.expected === 'number' + ? EXPECTED_NUMBER + : EXPECTED_SAFE_INTEGER, + }) + .nonnegative({ error: EXPECTED_NON_NEGATIVE_NUMBER }); +const IsoTimestampMillisecondsSchema = z.iso + .datetime({ offset: true, error: EXPECTED_ISO_TIMESTAMP }) + .transform(Date.parse) + .pipe( + z + .int({ error: EXPECTED_ISO_TIMESTAMP }) + .nonnegative({ error: EXPECTED_ISO_TIMESTAMP }) + ); + +const PayloadSchema = z + .object({ + kind: ExpectedStringSchema.optional(), + message: ExpectedStringSchema.optional(), + text: ExpectedStringSchema.optional(), + }) + .transform(({ kind, message: currentMessage, text: fallbackText }) => { + // Current OpenClaw runtime values use `message`. Accept `text` only as a + // fallback for the stale pinned 2026.5.28 plugin declaration. + const message = currentMessage ?? fallbackText; + return { + ...(kind === undefined ? {} : { kind }), + ...(message === undefined ? {} : { message }), + }; + }); + +const CronScheduleSchema = z + .object({ + kind: z.literal('cron'), + expr: ExpectedStringSchema.optional(), + tz: ExpectedStringSchema.optional(), + staggerMs: NaturalNumberSchema.optional(), + }) + .transform(({ kind, expr, tz, staggerMs }) => ({ + kind, + ...(expr === undefined ? {} : { expr }), + ...(tz === undefined ? {} : { tz }), + ...(staggerMs === undefined ? {} : { staggerMs }), + })); + +const AtScheduleSchema = z + .object({ + kind: z.literal('at'), + at: IsoTimestampMillisecondsSchema.optional(), + }) + .transform(({ kind, at }) => ({ + kind, + ...(at === undefined ? {} : { at }), + })); + +const EveryScheduleSchema = z + .object({ + kind: z.literal('every'), + everyMs: NaturalNumberSchema.optional(), + anchorMs: NaturalNumberSchema.optional(), + }) + .transform(({ kind, everyMs, anchorMs }) => ({ + kind, + ...(everyMs === undefined ? {} : { everyMs }), + ...(anchorMs === undefined ? {} : { anchorMs }), + })); + +const ScheduleSchema = z.discriminatedUnion('kind', [ + CronScheduleSchema, + AtScheduleSchema, + EveryScheduleSchema, +]); + +const CronJobSchema = z + .object({ + id: ExpectedStringSchema, + agentId: ExpectedStringSchema.optional(), + name: ExpectedStringSchema.optional(), + description: ExpectedStringSchema.optional(), + enabled: z.boolean({ error: 'expected a boolean' }).optional(), + schedule: ScheduleSchema.optional(), + sessionTarget: ExpectedStringSchema.optional(), + wakeMode: ExpectedStringSchema.optional(), + payload: PayloadSchema.optional(), + createdAtMs: NaturalNumberSchema.optional(), + updatedAtMs: NaturalNumberSchema.optional(), + }) + .transform( + ({ + id, + agentId, + name, + description, + enabled, + schedule, + sessionTarget, + wakeMode, + payload, + createdAtMs, + updatedAtMs, + }) => ({ + id, + ...(agentId === undefined ? {} : { agentId }), + ...(name === undefined ? {} : { name }), + ...(description === undefined ? {} : { description }), + ...(enabled === undefined ? {} : { enabled }), + ...(schedule === undefined ? {} : { schedule }), + ...(sessionTarget === undefined ? {} : { sessionTarget }), + ...(wakeMode === undefined ? {} : { wakeMode }), + ...(payload === undefined ? {} : { payload }), + ...(createdAtMs === undefined ? {} : { createdAtMs }), + ...(updatedAtMs === undefined ? {} : { updatedAtMs }), + }) + ); + +export type StewardAutomationSchedule = z.output; +export type StewardAutomationPayload = z.output; +export type StewardAutomationTask = z.output; + +export interface StewardAutomationProjection { + project: { + tasks: StewardAutomationTask[]; + }; +} + +const CronJobIdentitySchema = z.object({ id: z.string() }); +const ScheduleKindSchema = z.object({ + schedule: z.object({ kind: z.unknown() }), +}); + +function formatCronJobError(error: z.ZodError, job: unknown): Error { + const issue = error.issues[0]; + const identity = CronJobIdentitySchema.safeParse(job); + const jobLabel = identity.success + ? `cron job ${identity.data.id}` + : 'cron job'; + const path = issue?.path.map(String).join('.') ?? ''; + + if (path === 'schedule.kind' && issue?.code === 'invalid_union') { + const scheduleKind = ScheduleKindSchema.safeParse(job); + const kind = scheduleKind.success + ? String(scheduleKind.data.schedule.kind) + : 'undefined'; + return new Error( + `Invalid ${jobLabel} schedule.kind: unsupported value ${kind}` + ); + } + + const field = + path === 'id' ? 'cron job id' : [jobLabel, path].filter(Boolean).join(' '); + let message = issue?.message ?? 'invalid value'; + if (issue?.code === 'invalid_type' && issue.expected === 'object') { + message = 'expected an object'; + } + return new Error(`Invalid ${field}: ${message}`); +} + +function normalizeTask(job: PluginHookGatewayCronJob): StewardAutomationTask { + const parsed = CronJobSchema.safeParse(job); + if (!parsed.success) { + throw formatCronJobError(parsed.error, job); + } + return parsed.data; +} + +/** Normalize one complete OpenClaw cron list into Steward's `%project` JSON. */ +export function normalizeStewardAutomationProjection( + jobs: readonly PluginHookGatewayCronJob[] +): StewardAutomationProjection { + const seenIds = new Set(); + const tasks = jobs.map((job) => { + const task = normalizeTask(job); + if (seenIds.has(task.id)) { + throw new Error(`Duplicate cron job id: ${task.id}`); + } + seenIds.add(task.id); + return task; + }); + return { project: { tasks } }; +} diff --git a/packages/openclaw/src/steward-automation-reconciliation.test.ts b/packages/openclaw/src/steward-automation-reconciliation.test.ts new file mode 100644 index 0000000000..5da06f1bed --- /dev/null +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -0,0 +1,1240 @@ +import type { + OpenClawConfig, + OpenClawPluginApi, +} from 'openclaw/plugin-sdk/core'; +import type { + PluginHookCronChangedEvent, + PluginHookGatewayContext, + PluginHookGatewayCronJob, +} from 'openclaw/plugin-sdk/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { submitStewardAutomationProjection } from './steward-automation-adapter.js'; +import { + DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS, + StewardAutomationCronUnavailableError, + StewardAutomationReconciler, + StewardAutomationReconciliationCancelledError, + getStewardAutomationReconciler, + isStewardAutomationProjectionEligible, + reconcileStewardAutomation, + registerStewardAutomationReconciliationHooks, + setStewardAutomationReconciler, +} from './steward-automation-reconciliation.js'; + +vi.mock('./steward-automation-adapter.js', () => ({ + submitStewardAutomationProjection: vi.fn(), +})); + +type HookHandler = (event: unknown, context: unknown) => unknown; +type FakeHookApi = Pick & { + fire: (name: string, event: unknown, context: unknown) => Promise; +}; + +function createFakeHookApi(): FakeHookApi { + const handlers = new Map(); + const api = { + on: vi.fn((name: string, handler: HookHandler) => { + handlers.set(name, [...(handlers.get(name) ?? []), handler]); + }), + fire: async (name: string, event: unknown, context: unknown) => { + for (const handler of handlers.get(name) ?? []) { + await handler(event, context); + } + }, + }; + return api as unknown as FakeHookApi; +} + +function cronContext(jobs: PluginHookGatewayCronJob[]) { + const list = vi.fn().mockResolvedValue(jobs); + const context: Pick = { + getCron: () => ({ list }), + }; + return { context, list }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function job(id: string): PluginHookGatewayCronJob { + return { + id, + enabled: true, + payload: { kind: 'agentTurn', message: id }, + }; +} + +function controlledRetryDelay() { + const waits: ReturnType>[] = []; + const delay = vi.fn((delayMs: number) => { + expect(delayMs).toBe(DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS); + const wait = deferred(); + waits.push(wait); + return wait.promise; + }); + return { delay, waits }; +} + +const jobs = [ + { + id: 'disabled-job', + agentId: 'main', + name: 'Nightly status', + enabled: false, + schedule: { kind: 'cron', expr: '0 1 * * *', tz: 'UTC' }, + payload: { kind: 'agentTurn', message: 'check status' }, + state: { lastRunStatus: 'ok', lastRunAtMs: 1_777_000_000_000 }, + createdAtMs: 1_700_000_000_000, + }, +] satisfies PluginHookGatewayCronJob[]; + +beforeEach(() => { + getStewardAutomationReconciler()?.stop(); + setStewardAutomationReconciler(null); + vi.mocked(submitStewardAutomationProjection).mockReset(); + vi.mocked(submitStewardAutomationProjection).mockResolvedValue(undefined); +}); + +afterEach(() => { + getStewardAutomationReconciler()?.stop(); + setStewardAutomationReconciler(null); +}); + +describe('reconcileStewardAutomation', () => { + it('reads the complete list including disabled jobs and submits its normalized projection', async () => { + const { context, list } = cronContext(jobs); + + await reconcileStewardAutomation(context.getCron); + + expect(list).toHaveBeenCalledOnce(); + expect(list).toHaveBeenCalledWith({ includeDisabled: true }); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ + project: { + tasks: [ + { + id: 'disabled-job', + agentId: 'main', + name: 'Nightly status', + enabled: false, + schedule: { kind: 'cron', expr: '0 1 * * *', tz: 'UTC' }, + payload: { kind: 'agentTurn', message: 'check status' }, + createdAtMs: 1_700_000_000_000, + }, + ], + }, + }); + }); + + it('submits an empty complete projection after a successful empty read', async () => { + const { context } = cronContext([]); + + await reconcileStewardAutomation(context.getCron); + + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ + project: { tasks: [] }, + }); + }); + + it.each([ + ['missing getCron', undefined], + ['unready cron service', () => undefined], + ])('fails clearly for %s without submitting', async (_label, getCron) => { + await expect(reconcileStewardAutomation(getCron)).rejects.toMatchObject({ + name: 'StewardAutomationCronUnavailableError', + retryable: true, + }); + await expect(reconcileStewardAutomation(getCron)).rejects.toBeInstanceOf( + StewardAutomationCronUnavailableError + ); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + }); + + it('propagates read failures without submitting an empty projection', async () => { + const readError = new Error('cron list unavailable'); + const list = vi.fn().mockRejectedValue(readError); + + await expect(reconcileStewardAutomation(() => ({ list }))).rejects.toBe( + readError + ); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + }); + + it('propagates submission failures for later retry', async () => { + const submissionError = new Error('poke nack'); + const { context } = cronContext(jobs); + vi.mocked(submitStewardAutomationProjection).mockRejectedValue( + submissionError + ); + + await expect(reconcileStewardAutomation(context.getCron)).rejects.toBe( + submissionError + ); + }); +}); + +describe('StewardAutomationReconciler', () => { + it('serializes lists and coalesces a busy burst using the latest accessor', async () => { + const firstList = deferred(); + const list1 = vi.fn(() => firstList.promise); + const list2 = vi.fn().mockResolvedValue([job('stale-follow-up')]); + const list3 = vi.fn().mockResolvedValue([job('latest-follow-up')]); + const reconciler = new StewardAutomationReconciler(); + + const first = reconciler.start(() => ({ list: list1 })); + const stale = Array.from({ length: 8 }, () => + reconciler.trigger(() => ({ list: list2 })) + ); + const latest = reconciler.trigger(() => ({ list: list3 })); + + expect(list1).toHaveBeenCalledOnce(); + expect(list2).not.toHaveBeenCalled(); + expect(list3).not.toHaveBeenCalled(); + + firstList.resolve([job('first')]); + await first; + await Promise.all([...stale, latest]); + + expect(list1).toHaveBeenCalledOnce(); + expect(list2).not.toHaveBeenCalled(); + expect(list3).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenNthCalledWith(1, { + project: { tasks: [expect.objectContaining({ id: 'first' })] }, + }); + expect(submitStewardAutomationProjection).toHaveBeenNthCalledWith(2, { + project: { + tasks: [expect.objectContaining({ id: 'latest-follow-up' })], + }, + }); + }); + + it('waits for submission before starting a triggered follow-up', async () => { + const firstAcknowledgement = deferred(); + vi.mocked(submitStewardAutomationProjection) + .mockImplementationOnce(() => firstAcknowledgement.promise.then(() => {})) + .mockResolvedValueOnce(undefined); + const firstContext = cronContext([job('older')]); + const nextContext = cronContext([job('newer')]); + const reconciler = new StewardAutomationReconciler(); + + const first = reconciler.start(firstContext.context.getCron); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + const next = reconciler.trigger(nextContext.context.getCron); + + expect(nextContext.list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + + firstAcknowledgement.resolve(undefined); + await first; + await next; + + expect(nextContext.list).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenNthCalledWith(1, { + project: { tasks: [expect.objectContaining({ id: 'older' })] }, + }); + expect(submitStewardAutomationProjection).toHaveBeenNthCalledWith(2, { + project: { tasks: [expect.objectContaining({ id: 'newer' })] }, + }); + }); + + it('starts a new worker for a trigger arriving at worker settlement', async () => { + const secondRun = deferred(); + const reconcile = vi + .fn<() => Promise>() + .mockResolvedValueOnce(undefined) + .mockImplementationOnce(() => secondRun.promise); + const reconciler = new StewardAutomationReconciler(reconcile); + let secondSettled = false; + + const first = reconciler.start(undefined); + const second = first.then(() => + reconciler.trigger(undefined).then(() => { + secondSettled = true; + }) + ); + await first; + await vi.waitFor(() => { + expect(reconcile).toHaveBeenCalledTimes(2); + }); + + expect(secondSettled).toBe(false); + secondRun.resolve(); + await second; + expect(secondSettled).toBe(true); + }); + + it('keeps covered promises pending until a failed attempt retries successfully', async () => { + const firstRun = deferred(); + const secondRun = deferred(); + const { delay, waits } = controlledRetryDelay(); + const reconcile = vi + .fn<() => Promise>() + .mockImplementationOnce(() => firstRun.promise) + .mockImplementationOnce(() => secondRun.promise); + const reconciler = new StewardAutomationReconciler(reconcile, delay); + let firstSettled = false; + let pendingSettled = false; + + const first = reconciler.start(undefined).then(() => { + firstSettled = true; + }); + const pending = reconciler.trigger(undefined).then(() => { + pendingSettled = true; + }); + firstRun.reject(new Error('first reconciliation failed')); + + await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); + expect(firstSettled).toBe(false); + expect(pendingSettled).toBe(false); + expect(reconcile).toHaveBeenCalledOnce(); + + waits[0].resolve(); + await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2)); + expect(firstSettled).toBe(false); + expect(pendingSettled).toBe(false); + + secondRun.resolve(); + await Promise.all([first, pending]); + expect(firstSettled).toBe(true); + expect(pendingSettled).toBe(true); + }); + + it.each([ + ['missing accessor', undefined], + ['missing service', () => undefined], + ])( + 'recovers from an initially %s using the latest accessor', + async (_label, unavailable) => { + const { delay, waits } = controlledRetryDelay(); + const recovered = cronContext([job('recovered')]); + const reconciler = new StewardAutomationReconciler( + reconcileStewardAutomation, + delay + ); + + const initial = reconciler.start(unavailable); + await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); + const repair = reconciler.trigger(recovered.context.getCron); + + expect(recovered.list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + waits[0].resolve(); + await Promise.all([initial, repair]); + + expect(recovered.list).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + } + ); + + it('preserves a delivered snapshot until unavailable startup recovers', async () => { + const { delay, waits } = controlledRetryDelay(); + const previous = cronContext([job('previous')]); + const latest = cronContext([job('latest')]); + const reconciler = new StewardAutomationReconciler( + reconcileStewardAutomation, + delay + ); + + await reconciler.start(previous.context.getCron); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenLastCalledWith({ + project: { tasks: [expect.objectContaining({ id: 'previous' })] }, + }); + + reconciler.stop(); + let startupSettled = false; + const startup = reconciler.start(undefined).then(() => { + startupSettled = true; + }); + await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); + + let recoverySettled = false; + const recovery = reconciler.trigger(latest.context.getCron).then(() => { + recoverySettled = true; + }); + expect(startupSettled).toBe(false); + expect(recoverySettled).toBe(false); + expect(latest.list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenLastCalledWith({ + project: { tasks: [expect.objectContaining({ id: 'previous' })] }, + }); + + waits[0].resolve(); + await Promise.all([startup, recovery]); + + expect(startupSettled).toBe(true); + expect(recoverySettled).toBe(true); + expect(latest.list).toHaveBeenCalledOnce(); + expect(latest.list).toHaveBeenCalledWith({ includeDisabled: true }); + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenLastCalledWith({ + project: { tasks: [expect.objectContaining({ id: 'latest' })] }, + }); + }); + + it('retries a failed list without submitting an empty projection', async () => { + const { delay, waits } = controlledRetryDelay(); + const list = vi + .fn() + .mockRejectedValueOnce(new Error('cron unavailable')) + .mockResolvedValueOnce([job('after-list-recovery')]); + const reconciler = new StewardAutomationReconciler( + reconcileStewardAutomation, + delay + ); + + const result = reconciler.start(() => ({ list })); + await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + + waits[0].resolve(); + await result; + expect(list).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ + project: { + tasks: [expect.objectContaining({ id: 'after-list-recovery' })], + }, + }); + }); + + it('retries normalization and submission failures as complete operations', async () => { + const { delay, waits } = controlledRetryDelay(); + const invalid = { + ...job('invalid'), + schedule: { kind: 'future-schedule' }, + } as unknown as PluginHookGatewayCronJob; + const list = vi + .fn() + .mockResolvedValueOnce([invalid]) + .mockResolvedValue([job('valid')]); + vi.mocked(submitStewardAutomationProjection) + .mockRejectedValueOnce(new Error('poke nack')) + .mockResolvedValueOnce(undefined); + const reconciler = new StewardAutomationReconciler( + reconcileStewardAutomation, + delay + ); + + const result = reconciler.start(() => ({ list })); + await vi.waitFor(() => expect(delay).toHaveBeenCalledTimes(1)); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + waits[0].resolve(); + + await vi.waitFor(() => expect(delay).toHaveBeenCalledTimes(2)); + expect(list).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + waits[1].resolve(); + + await result; + expect(list).toHaveBeenCalledTimes(3); + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenLastCalledWith({ + project: { tasks: [expect.objectContaining({ id: 'valid' })] }, + }); + }); + + it('schedules one delay for a failed burst and retries with only the latest accessor', async () => { + const failedList = deferred(); + const { delay, waits } = controlledRetryDelay(); + const firstList = vi.fn(() => failedList.promise); + const staleList = vi.fn().mockResolvedValue([job('stale')]); + const latestList = vi.fn().mockResolvedValue([job('latest')]); + const reconciler = new StewardAutomationReconciler( + reconcileStewardAutomation, + delay + ); + + const initial = reconciler.start(() => ({ list: firstList })); + const stale = Array.from({ length: 6 }, () => + reconciler.trigger(() => ({ list: staleList })) + ); + failedList.reject(new Error('list failed')); + await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); + const latest = reconciler.trigger(() => ({ list: latestList })); + + expect(staleList).not.toHaveBeenCalled(); + expect(latestList).not.toHaveBeenCalled(); + waits[0].resolve(); + await Promise.all([initial, ...stale, latest]); + + expect(delay).toHaveBeenCalledOnce(); + expect(staleList).not.toHaveBeenCalled(); + expect(latestList).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + + it('submits an empty projection only after an actual successful empty list', async () => { + const { delay, waits } = controlledRetryDelay(); + const list = vi + .fn() + .mockRejectedValueOnce(new Error('read failed')) + .mockResolvedValueOnce([]); + const reconciler = new StewardAutomationReconciler( + reconcileStewardAutomation, + delay + ); + + const result = reconciler.start(() => ({ list })); + await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + + waits[0].resolve(); + await result; + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ + project: { tasks: [] }, + }); + }); + + it('times out a hung list, fences its late result, and retries', async () => { + const hungList = deferred(); + const { delay, waits } = controlledRetryDelay(); + const list = vi + .fn() + .mockImplementationOnce(() => hungList.promise) + .mockResolvedValueOnce([job('recovered')]); + const reconciler = new StewardAutomationReconciler( + reconcileStewardAutomation, + delay, + DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS, + 10 + ); + + const result = reconciler.start(() => ({ list })); + await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + + hungList.resolve([job('stale')]); + waits[0].resolve(); + await result; + + expect(list).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ + project: { tasks: [expect.objectContaining({ id: 'recovered' })] }, + }); + }); + + it('times out a hung submission and retries the complete operation', async () => { + const hungSubmission = deferred(); + const { delay, waits } = controlledRetryDelay(); + const list = vi + .fn() + .mockResolvedValueOnce([job('first')]) + .mockResolvedValueOnce([job('recovered')]); + vi.mocked(submitStewardAutomationProjection) + .mockImplementationOnce(() => hungSubmission.promise) + .mockResolvedValueOnce(undefined); + const reconciler = new StewardAutomationReconciler( + reconcileStewardAutomation, + delay, + DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS, + 10 + ); + + const result = reconciler.start(() => ({ list })); + await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + + waits[0].resolve(); + await result; + + expect(list).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenLastCalledWith({ + project: { tasks: [expect.objectContaining({ id: 'recovered' })] }, + }); + + // The deadline observer must consume a rejection from abandoned work. + hungSubmission.reject(new Error('late poke failure')); + await Promise.resolve(); + }); + + it('cancels an active retry delay and does not start another attempt', async () => { + const retryStarted = deferred(); + const delay = vi.fn((_delayMs: number, signal?: AbortSignal) => { + retryStarted.resolve(); + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }); + }); + const reconcile = vi.fn().mockRejectedValue(new Error('offline')); + const reconciler = new StewardAutomationReconciler(reconcile, delay); + + const result = reconciler.start(undefined); + const cancelled = expect(result).rejects.toBeInstanceOf( + StewardAutomationReconciliationCancelledError + ); + await retryStarted.promise; + reconciler.stop(); + await cancelled; + + expect(reconcile).toHaveBeenCalledOnce(); + expect(delay).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + }); + + it('stops during an outstanding list without submitting or retrying', async () => { + const listed = deferred(); + const list = vi.fn(() => listed.promise); + const delay = vi.fn(); + const reconciler = new StewardAutomationReconciler( + reconcileStewardAutomation, + delay + ); + + const result = reconciler.start(() => ({ list })); + const cancelled = expect(result).rejects.toMatchObject({ + name: 'StewardAutomationReconciliationCancelledError', + reason: 'gateway-stop', + }); + reconciler.stop(); + listed.resolve([]); + await cancelled; + await vi.waitFor(() => expect(list).toHaveBeenCalledOnce()); + + expect(delay).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + }); + + it('checks the active epoch at an injected pre-submit boundary', async () => { + const atBoundary = deferred(); + const releaseBoundary = deferred(); + const reconcile = ( + getCron: Parameters[0], + _beforeSubmit?: () => void | Promise, + assertCanSubmit?: () => void + ) => + reconcileStewardAutomation( + getCron, + async () => { + atBoundary.resolve(); + await releaseBoundary.promise; + }, + assertCanSubmit + ); + const reconciler = new StewardAutomationReconciler(reconcile); + const { context } = cronContext([job('stale')]); + + const result = reconciler.start(context.getCron); + const cancelled = expect(result).rejects.toBeInstanceOf( + StewardAutomationReconciliationCancelledError + ); + await atBoundary.promise; + reconciler.stop(); + releaseBoundary.resolve(); + await cancelled; + + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + }); + + it('clears coalesced pending triggers when the gateway stops', async () => { + const listed = deferred(); + const firstList = vi.fn(() => listed.promise); + const pendingList = vi.fn().mockResolvedValue([]); + const reconciler = new StewardAutomationReconciler(); + + const first = reconciler.start(() => ({ list: firstList })); + const pending1 = reconciler.trigger(() => ({ list: pendingList })); + const pending2 = reconciler.trigger(() => ({ list: pendingList })); + const cancellations = [first, pending1, pending2].map((promise) => + expect(promise).rejects.toBeInstanceOf( + StewardAutomationReconciliationCancelledError + ) + ); + + reconciler.stop(); + listed.resolve([]); + await Promise.all(cancellations); + await vi.waitFor(() => expect(firstList).toHaveBeenCalledOnce()); + + expect(pendingList).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + }); + + it('restarts without waiting for a hung prior-epoch list', async () => { + const hungList = deferred(); + const staleList = vi.fn(() => hungList.promise); + const freshList = vi.fn().mockResolvedValue([job('fresh')]); + const reconciler = new StewardAutomationReconciler(); + + const stale = reconciler.start(() => ({ list: staleList })); + const staleCancelled = expect(stale).rejects.toMatchObject({ + name: 'StewardAutomationReconciliationCancelledError', + reason: 'gateway-stop', + }); + reconciler.stop(); + const fresh = reconciler.start(() => ({ list: freshList })); + + await staleCancelled; + await fresh; + + expect(staleList).toHaveBeenCalledOnce(); + expect(freshList).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ + project: { tasks: [expect.objectContaining({ id: 'fresh' })] }, + }); + }); + + it('restarts without waiting for a hung prior-epoch submission', async () => { + const hungSubmission = deferred(); + vi.mocked(submitStewardAutomationProjection) + .mockImplementationOnce(() => hungSubmission.promise) + .mockResolvedValueOnce(undefined); + const stale = cronContext([job('stale')]); + const fresh = cronContext([job('fresh')]); + const reconciler = new StewardAutomationReconciler(); + + const staleResult = reconciler.start(stale.context.getCron); + const staleCancelled = expect(staleResult).rejects.toMatchObject({ + name: 'StewardAutomationReconciliationCancelledError', + reason: 'gateway-stop', + }); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + reconciler.stop(); + const freshResult = reconciler.start(fresh.context.getCron); + + await staleCancelled; + await freshResult; + + expect(stale.list).toHaveBeenCalledOnce(); + expect(fresh.list).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenLastCalledWith({ + project: { tasks: [expect.objectContaining({ id: 'fresh' })] }, + }); + }); + + it('restarts with one fresh snapshot and blocks the stale prior epoch', async () => { + const staleListResult = deferred(); + const staleList = vi.fn(() => staleListResult.promise); + const freshList = vi.fn().mockResolvedValue([job('fresh')]); + const reconciler = new StewardAutomationReconciler(); + + const stale = reconciler.start(() => ({ list: staleList })); + const staleCancelled = expect(stale).rejects.toMatchObject({ + name: 'StewardAutomationReconciliationCancelledError', + reason: 'gateway-stop', + }); + reconciler.stop(); + const fresh = reconciler.start(() => ({ list: freshList })); + + staleListResult.resolve([job('stale')]); + await staleCancelled; + await fresh; + + expect(staleList).toHaveBeenCalledOnce(); + expect(freshList).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ + project: { tasks: [expect.objectContaining({ id: 'fresh' })] }, + }); + }); +}); + +describe('registerStewardAutomationReconciliationHooks', () => { + const oneRunnableAccount = { + channels: { + tlon: { + ship: '~zod', + url: 'http://zod.test', + code: 'lidlut-tabwed-pillex-ridrup', + }, + }, + } as OpenClawConfig; + const twoRunnableAccounts = { + channels: { + tlon: { + ship: '~zod', + url: 'http://zod.test', + code: 'lidlut-tabwed-pillex-ridrup', + accounts: { + second: { + ship: '~bus', + url: 'http://bus.test', + code: 'racmut-batdur-sivhes-nidweb', + }, + }, + }, + }, + } as OpenClawConfig; + const oneRunnableWithIncompleteAccount = { + channels: { + tlon: { + accounts: { + primary: { + ship: '~zod', + url: 'http://zod.test', + code: 'lidlut-tabwed-pillex-ridrup', + }, + incomplete: { + ship: '~bus', + url: 'http://bus.test', + }, + }, + }, + }, + } as OpenClawConfig; + const zeroRunnableAccounts = { channels: {} } as OpenClawConfig; + const registrationOptions = ( + getConfig: () => OpenClawConfig = () => oneRunnableAccount + ) => ({ + logger: { warn: vi.fn() }, + getConfig, + }); + + it('requires exactly one runnable Tlon account', () => { + const disabledSecondAccount = structuredClone(twoRunnableAccounts); + const tlon = disabledSecondAccount.channels?.tlon as { + accounts: { second: { enabled?: boolean } }; + }; + tlon.accounts.second.enabled = false; + + expect(isStewardAutomationProjectionEligible(oneRunnableAccount)).toBe( + true + ); + expect(isStewardAutomationProjectionEligible(twoRunnableAccounts)).toBe( + false + ); + expect(isStewardAutomationProjectionEligible(disabledSecondAccount)).toBe( + true + ); + expect( + isStewardAutomationProjectionEligible(oneRunnableWithIncompleteAccount) + ).toBe(true); + expect(isStewardAutomationProjectionEligible(zeroRunnableAccounts)).toBe( + false + ); + }); + + it('disables projection when multiple runnable accounts are configured', async () => { + const api = createFakeHookApi(); + const options = registrationOptions(() => twoRunnableAccounts); + const { context, list } = cronContext(jobs); + registerStewardAutomationReconciliationHooks(api, options); + + await api.fire('gateway_start', { port: 3000 }, context); + await api.fire( + 'cron_changed', + { action: 'updated', jobId: 'disabled-job' }, + context + ); + + expect(list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + expect(options.logger.warn).toHaveBeenCalledOnce(); + expect(options.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('2 runnable Tlon accounts') + ); + }); + + it('keeps projection inactive when no account is runnable', async () => { + const api = createFakeHookApi(); + const options = registrationOptions(() => zeroRunnableAccounts); + const { context, list } = cronContext(jobs); + registerStewardAutomationReconciliationHooks(api, options); + + await api.fire('gateway_start', { port: 3000 }, context); + + expect(list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + expect(options.logger.warn).not.toHaveBeenCalled(); + }); + + it('stops projection after a one-to-many account transition', async () => { + let config = oneRunnableAccount; + const api = createFakeHookApi(); + const options = registrationOptions(() => config); + const { context, list } = cronContext(jobs); + registerStewardAutomationReconciliationHooks(api, options); + + await api.fire('gateway_start', { port: 3000 }, context); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + list.mockClear(); + vi.mocked(submitStewardAutomationProjection).mockClear(); + + config = twoRunnableAccounts; + await api.fire( + 'cron_changed', + { action: 'updated', jobId: 'disabled-job' }, + context + ); + + expect(list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + expect(options.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('projection disabled') + ); + }); + + it('stops projection after a one-to-zero account transition', async () => { + let config = oneRunnableAccount; + const api = createFakeHookApi(); + const options = registrationOptions(() => config); + const { context, list } = cronContext(jobs); + registerStewardAutomationReconciliationHooks(api, options); + + await api.fire('gateway_start', { port: 3000 }, context); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + list.mockClear(); + vi.mocked(submitStewardAutomationProjection).mockClear(); + + config = zeroRunnableAccounts; + await api.fire( + 'cron_changed', + { action: 'updated', jobId: 'disabled-job' }, + context + ); + + expect(list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + expect(options.logger.warn).not.toHaveBeenCalled(); + }); + + it('cancels an in-flight epoch when configuration becomes ambiguous', async () => { + let config = oneRunnableAccount; + const api = createFakeHookApi(); + const options = registrationOptions(() => config); + const listed = deferred(); + const list = vi.fn(() => listed.promise); + const context = { getCron: () => ({ list }) }; + const reconciler = registerStewardAutomationReconciliationHooks( + api, + options + ); + + const active = reconciler.start(context.getCron); + const cancelled = expect(active).rejects.toBeInstanceOf( + StewardAutomationReconciliationCancelledError + ); + config = twoRunnableAccounts; + await api.fire( + 'cron_changed', + { action: 'updated', jobId: 'disabled-job' }, + context + ); + + await cancelled; + expect(list).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + }); + + it('registers gateway_stop and ignores cron changes while inactive', async () => { + const api = createFakeHookApi(); + const { context, list } = cronContext(jobs); + registerStewardAutomationReconciliationHooks(api, registrationOptions()); + + expect(api.on).toHaveBeenCalledWith('gateway_stop', expect.any(Function)); + await api.fire( + 'cron_changed', + { action: 'added', jobId: 'disabled-job' }, + context + ); + expect(list).not.toHaveBeenCalled(); + + await api.fire('gateway_start', { port: 3000 }, context); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + await api.fire('gateway_stop', { reason: 'shutdown' }, context); + list.mockClear(); + vi.mocked(submitStewardAutomationProjection).mockClear(); + + await api.fire( + 'cron_changed', + { action: 'removed', jobId: 'disabled-job' }, + context + ); + expect(list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + }); + + it('reconciles after gateway_start', async () => { + const api = createFakeHookApi(); + const { context, list } = cronContext(jobs); + registerStewardAutomationReconciliationHooks(api, registrationOptions()); + + await api.fire('gateway_start', { port: 3000 }, context); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + + expect(list).toHaveBeenCalledWith({ includeDisabled: true }); + }); + + it.each<{ + category: 'definition' | 'execution'; + action: PluginHookCronChangedEvent['action']; + }>([ + { category: 'definition', action: 'added' }, + { category: 'definition', action: 'updated' }, + { category: 'definition', action: 'removed' }, + { category: 'execution', action: 'started' }, + { category: 'execution', action: 'finished' }, + ])( + 'rereads the complete list after the $category $action event', + async ({ action }) => { + const api = createFakeHookApi(); + const completeJobs = [ + { + id: 'complete-enabled', + enabled: true, + payload: { kind: 'agentTurn', message: 'first in complete list' }, + }, + { + id: 'complete-disabled', + enabled: false, + payload: { kind: 'agentTurn', message: 'second in complete list' }, + }, + ] satisfies PluginHookGatewayCronJob[]; + const { context, list } = cronContext(completeJobs); + registerStewardAutomationReconciliationHooks(api, registrationOptions()); + + await api.fire('gateway_start', { port: 3000 }, context); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + list.mockClear(); + vi.mocked(submitStewardAutomationProjection).mockClear(); + + await api.fire( + 'cron_changed', + { + action, + jobId: 'event-only', + job: { + id: 'event-only', + enabled: true, + payload: { kind: 'agentTurn', message: 'event delta' }, + state: { lastRunStatus: 'ok' }, + }, + }, + context + ); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + + expect(list).toHaveBeenCalledOnce(); + expect(list).toHaveBeenCalledWith({ includeDisabled: true }); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ + project: { + tasks: [ + { + id: 'complete-enabled', + enabled: true, + payload: { + kind: 'agentTurn', + message: 'first in complete list', + }, + }, + { + id: 'complete-disabled', + enabled: false, + payload: { + kind: 'agentTurn', + message: 'second in complete list', + }, + }, + ], + }, + }); + } + ); + + it('reuses one reconciler across discovery, full, and prewarm registries', async () => { + const discoveryApi = createFakeHookApi(); + const fullApi = createFakeHookApi(); + const prewarmApi = createFakeHookApi(); + const options = registrationOptions(); + const discovery = registerStewardAutomationReconciliationHooks( + discoveryApi, + options + ); + const full = registerStewardAutomationReconciliationHooks(fullApi, options); + const initial = cronContext([job('initial')]); + + expect(full).toBe(discovery); + expect(getStewardAutomationReconciler()).toBe(discovery); + await fullApi.fire('gateway_start', { port: 3000 }, initial.context); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + + const prewarm = registerStewardAutomationReconciliationHooks( + prewarmApi, + options + ); + const changed = cronContext([job('changed')]); + expect(prewarm).toBe(discovery); + await prewarmApi.fire( + 'cron_changed', + { action: 'updated', jobId: 'changed' }, + changed.context + ); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); + }); + + await prewarmApi.fire( + 'gateway_stop', + { reason: 'shutdown' }, + changed.context + ); + const ignored = cronContext([job('ignored')]); + await discoveryApi.fire( + 'cron_changed', + { action: 'removed', jobId: 'changed' }, + ignored.context + ); + expect(ignored.list).not.toHaveBeenCalled(); + + const restarted = cronContext([job('restarted')]); + await discoveryApi.fire('gateway_start', { port: 3001 }, restarted.context); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(3); + }); + expect(restarted.list).toHaveBeenCalledOnce(); + }); + + it('treats duplicate gateway_start delivery as idempotent', async () => { + const api1 = createFakeHookApi(); + const api2 = createFakeHookApi(); + const options = registrationOptions(); + registerStewardAutomationReconciliationHooks(api1, options); + registerStewardAutomationReconciliationHooks(api2, options); + const initial = cronContext([job('initial')]); + const duplicate = cronContext([job('duplicate')]); + + await api1.fire('gateway_start', { port: 3000 }, initial.context); + await vi.waitFor(() => { + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + await api2.fire('gateway_start', { port: 3000 }, duplicate.context); + await Promise.resolve(); + + expect(initial.list).toHaveBeenCalledOnce(); + expect(duplicate.list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + }); + + it('dispatches projection work without awaiting an outstanding list', async () => { + const api = createFakeHookApi(); + const options = registrationOptions(); + const listed = deferred(); + const list = vi.fn(() => listed.promise); + registerStewardAutomationReconciliationHooks(api, options); + + await api.fire( + 'gateway_start', + { port: 3000 }, + { + getCron: () => ({ list }), + } + ); + + expect(list).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); + await api.fire('gateway_stop', { reason: 'shutdown' }, {}); + listed.resolve([]); + await Promise.resolve(); + expect(options.logger.warn).not.toHaveBeenCalled(); + }); + + it('suppresses cancellation but logs unexpected terminal errors without suppressing another hook', async () => { + const api = createFakeHookApi(); + const options = registrationOptions(); + const terminal = new Error('terminal projection failure'); + const injected = { + start: vi.fn().mockRejectedValue(terminal), + trigger: vi.fn().mockResolvedValue(undefined), + stop: vi.fn(), + } as unknown as StewardAutomationReconciler; + setStewardAutomationReconciler(injected); + const telemetry = vi.fn(); + registerStewardAutomationReconciliationHooks(api, options); + api.on('gateway_start', telemetry); + + await api.fire('gateway_start', { port: 3000 }, { getCron: undefined }); + + expect(telemetry).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(options.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('terminal projection failure') + ); + }); + expect(getStewardAutomationReconciler()).toBe(injected); + }); + + it('fails closed when live configuration cannot be loaded', async () => { + const api = createFakeHookApi(); + const injected = { + start: vi.fn().mockResolvedValue(undefined), + trigger: vi.fn().mockResolvedValue(undefined), + stop: vi.fn(), + } as unknown as StewardAutomationReconciler; + const warn = vi.fn(() => { + throw new Error('logger unavailable'); + }); + setStewardAutomationReconciler(injected); + registerStewardAutomationReconciliationHooks(api, { + logger: { warn }, + getConfig: () => { + throw new Error('config unavailable'); + }, + }); + + await expect( + api.fire('gateway_start', { port: 3000 }, { getCron: undefined }) + ).resolves.toBeUndefined(); + + expect(injected.stop).toHaveBeenCalledOnce(); + expect(injected.start).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + }); + + it('contains a logger failure while observing a terminal rejection', async () => { + const api = createFakeHookApi(); + const terminal = new Error('terminal projection failure'); + const injected = { + start: vi.fn().mockRejectedValue(terminal), + trigger: vi.fn().mockResolvedValue(undefined), + stop: vi.fn(), + } as unknown as StewardAutomationReconciler; + const warn = vi.fn(() => { + throw new Error('logger unavailable'); + }); + setStewardAutomationReconciler(injected); + registerStewardAutomationReconciliationHooks(api, { + logger: { warn }, + getConfig: () => oneRunnableAccount, + }); + + await api.fire('gateway_start', { port: 3000 }, { getCron: undefined }); + await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce()); + }); +}); diff --git a/packages/openclaw/src/steward-automation-reconciliation.ts b/packages/openclaw/src/steward-automation-reconciliation.ts new file mode 100644 index 0000000000..7e1f8dd622 --- /dev/null +++ b/packages/openclaw/src/steward-automation-reconciliation.ts @@ -0,0 +1,583 @@ +import type { + OpenClawConfig, + OpenClawPluginApi, +} from 'openclaw/plugin-sdk/core'; +import type { PluginHookGatewayCronService } from 'openclaw/plugin-sdk/types'; + +import { sharedSlot } from './shared-state.js'; +import { submitStewardAutomationProjection } from './steward-automation-adapter.js'; +import { normalizeStewardAutomationProjection } from './steward-automation-projection.js'; +import { listRunnableTlonAccountIds } from './types.js'; + +type StewardAutomationCronService = Pick; + +export type StewardAutomationCronAccessor = + | (() => StewardAutomationCronService | undefined) + | undefined; + +type StewardAutomationSubmissionGuard = () => void | Promise; + +type StewardAutomationReconciliation = ( + getCron: StewardAutomationCronAccessor, + beforeSubmit?: StewardAutomationSubmissionGuard, + assertCanSubmit?: () => void, + signal?: AbortSignal +) => Promise; + +interface ReconciliationWaiter { + resolve: () => void; + reject: (error: unknown) => void; +} + +interface PendingReconciliation { + epoch: number; + controller: AbortController; + getCron: StewardAutomationCronAccessor; + waiters: ReconciliationWaiter[]; + settled: boolean; +} + +export const DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS = 5_000; +export const DEFAULT_STEWARD_AUTOMATION_OPERATION_TIMEOUT_MS = 30_000; + +export class StewardAutomationReconciliationTimeoutError extends Error { + readonly retryable = true; + + constructor( + readonly phase: 'read' | 'submission', + readonly timeoutMs: number + ) { + super( + `Steward automation ${phase} timed out after ${timeoutMs}ms; ` + + 'the complete reconciliation will be retried' + ); + this.name = 'StewardAutomationReconciliationTimeoutError'; + } +} + +function withReconciliationDeadline( + operation: (signal: AbortSignal) => Promise, + epochSignal: AbortSignal, + timeoutMs: number, + getPhase: () => 'read' | 'submission' +): Promise { + const controller = new AbortController(); + const abortFromEpoch = () => controller.abort(epochSignal.reason); + + if (epochSignal.aborted) { + abortFromEpoch(); + } else { + epochSignal.addEventListener('abort', abortFromEpoch, { once: true }); + } + + const timeout = setTimeout(() => { + controller.abort( + new StewardAutomationReconciliationTimeoutError(getPhase(), timeoutMs) + ); + }, timeoutMs); + timeout.unref?.(); + + let work: Promise; + try { + work = operation(controller.signal); + } catch (error) { + work = Promise.reject(error); + } + + return new Promise((resolve, reject) => { + let settled = false; + + const cleanup = () => { + clearTimeout(timeout); + epochSignal.removeEventListener('abort', abortFromEpoch); + controller.signal.removeEventListener('abort', onAbort); + }; + const settle = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + callback(); + }; + const onAbort = () => + settle(() => + reject( + controller.signal.reason ?? + new Error('Steward automation reconciliation was aborted') + ) + ); + + if (controller.signal.aborted) { + onAbort(); + } else { + controller.signal.addEventListener('abort', onAbort, { once: true }); + } + + // Keep handlers attached after an abort or timeout so a late rejection + // from a transport that cannot be cancelled is still observed. + work.then( + (value) => settle(() => resolve(value)), + (error) => settle(() => reject(error)) + ); + }); +} + +export type StewardAutomationRetryDelay = ( + delayMs: number, + signal?: AbortSignal +) => Promise; + +const waitForRetryDelay: StewardAutomationRetryDelay = (delayMs, signal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const timeout = setTimeout(onElapsed, delayMs); + function onElapsed() { + signal?.removeEventListener('abort', onAborted); + resolve(); + } + function onAborted() { + clearTimeout(timeout); + reject(signal?.reason); + } + signal?.addEventListener('abort', onAborted, { once: true }); + }); + +export class StewardAutomationCronUnavailableError extends Error { + readonly retryable = true; + + constructor(reason: 'missing-accessor' | 'missing-service') { + const detail = + reason === 'missing-accessor' + ? 'the gateway hook context did not provide getCron' + : 'getCron did not provide a cron service'; + super( + `Steward automation reconciliation is temporarily unavailable: ${detail}` + ); + this.name = 'StewardAutomationCronUnavailableError'; + } +} + +export class StewardAutomationReconciliationCancelledError extends Error { + readonly retryable = false; + + constructor( + readonly epoch: number, + readonly reason: 'gateway-stop' | 'gateway-restart' + ) { + super( + `Steward automation reconciliation for gateway epoch ${epoch} was ` + + `cancelled by ${reason}` + ); + this.name = 'StewardAutomationReconciliationCancelledError'; + } +} + +/** Read and submit one complete snapshot from the pinned gateway cron API. */ +export async function reconcileStewardAutomation( + getCron: StewardAutomationCronAccessor, + beforeSubmit?: StewardAutomationSubmissionGuard, + assertCanSubmit?: () => void, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted(); + if (!getCron) { + throw new StewardAutomationCronUnavailableError('missing-accessor'); + } + + const cron = getCron(); + if (!cron) { + throw new StewardAutomationCronUnavailableError('missing-service'); + } + + const jobs = await cron.list({ includeDisabled: true }); + signal?.throwIfAborted(); + const projection = normalizeStewardAutomationProjection(jobs); + await beforeSubmit?.(); + signal?.throwIfAborted(); + // Keep these synchronous checks adjacent to invoking the adapter. Awaiting + // a lifecycle guard here would reopen a microtask-sized stale-submit race. + assertCanSubmit?.(); + await submitStewardAutomationProjection(projection); +} + +/** + * Owns serialized reconciliation for reusable gateway lifecycle epochs. + * + * `start` creates an epoch and requests its full snapshot, while duplicate + * starts during that epoch are ignored. Active triggers are coalesced. `stop` + * cancels retry delays and abandons in-flight operation waits, rejects + * outstanding promises with a typed cancellation, and leaves durable Steward + * state untouched. Each read-and-submit attempt also has a deadline so a hung + * dependency cannot own the process-lifetime worker forever. A stopped + * reconciler ignores later change triggers until a new `start` creates a fresh + * epoch. + */ +export class StewardAutomationReconciler { + private pending: PendingReconciliation | null = null; + private current: PendingReconciliation | null = null; + private running = false; + private epoch = 0; + private activeEpoch: number | null = null; + private activeController: AbortController | null = null; + + constructor( + private readonly reconcile: StewardAutomationReconciliation = reconcileStewardAutomation, + private readonly retryDelay: StewardAutomationRetryDelay = waitForRetryDelay, + private readonly retryDelayMs = DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS, + private readonly operationTimeoutMs = DEFAULT_STEWARD_AUTOMATION_OPERATION_TIMEOUT_MS + ) {} + + start(getCron: StewardAutomationCronAccessor): Promise { + // registerFull can bind this process-lifetime reconciler into several hook + // registries. A duplicate gateway_start from another registry belongs to + // the already-active gateway lifecycle and must not restart its worker. + if (this.activeEpoch !== null) { + return Promise.resolve(); + } + + const epoch = ++this.epoch; + const controller = new AbortController(); + this.activeEpoch = epoch; + this.activeController = controller; + return this.enqueue(epoch, controller, getCron); + } + + /** Ignore cron changes safely while no gateway epoch is active. */ + trigger(getCron: StewardAutomationCronAccessor): Promise { + if (this.activeEpoch === null || this.activeController === null) { + return Promise.resolve(); + } + return this.enqueue(this.activeEpoch, this.activeController, getCron); + } + + stop(): void { + const epoch = this.activeEpoch; + if (epoch === null) { + return; + } + + const cancellation = new StewardAutomationReconciliationCancelledError( + epoch, + 'gateway-stop' + ); + this.activeEpoch = null; + this.activeController?.abort(cancellation); + this.activeController = null; + + if (this.current?.epoch === epoch) { + this.rejectBatch(this.current, cancellation); + } + if (this.pending?.epoch === epoch) { + const pending = this.takePending(); + if (pending) { + this.rejectBatch(pending, cancellation); + } + } + } + + private enqueue( + epoch: number, + controller: AbortController, + getCron: StewardAutomationCronAccessor + ): Promise { + const promise = new Promise((resolve, reject) => { + const waiter = { resolve, reject }; + + if (this.pending) { + if ( + this.pending.epoch !== epoch || + this.pending.controller !== controller + ) { + reject( + new Error( + `Reconciler invariant violated: pending epoch ` + + `${this.pending.epoch}, requested epoch ${epoch}` + ) + ); + return; + } + this.pending.getCron = getCron; + this.pending.waiters.push(waiter); + } else { + this.pending = { + epoch, + controller, + getCron, + waiters: [waiter], + settled: false, + }; + } + }); + + if (!this.running) { + this.running = true; + void this.drain(); + } + + return promise; + } + + private async drain(): Promise { + try { + for (;;) { + const batch = this.takePending(); + if (!batch) { + break; + } + this.current = batch; + + for (;;) { + if (!this.isActiveEpoch(batch.epoch)) { + this.rejectBatch(batch, this.cancellationFor(batch.epoch)); + break; + } + + try { + let phase: 'read' | 'submission' = 'read'; + // The deadline bounds our wait; it cannot revoke a remote side + // effect after the adapter has issued it. Keeping the late work + // observed prevents unhandled rejections, while the next attempt + // rereads the complete authoritative snapshot. + await withReconciliationDeadline( + (signal) => + this.reconcile( + batch.getCron, + () => { + phase = 'submission'; + }, + () => this.assertActiveEpoch(batch.epoch), + signal + ), + batch.controller.signal, + this.operationTimeoutMs, + () => phase + ); + this.assertActiveEpoch(batch.epoch); + this.resolveBatch(batch); + break; + } catch (error) { + if (!this.isActiveEpoch(batch.epoch)) { + this.rejectBatch(batch, this.cancellationFor(batch.epoch)); + break; + } + + try { + await this.retryDelay(this.retryDelayMs, batch.controller.signal); + } catch (delayError) { + this.rejectBatch(batch, delayError); + break; + } + + if (!this.isActiveEpoch(batch.epoch)) { + this.rejectBatch(batch, this.cancellationFor(batch.epoch)); + break; + } + + // Triggers received during the delay join this retry. A pending + // batch from a newer restarted epoch remains separate. + if (this.pending?.epoch === batch.epoch) { + const pending = this.takePending(); + if (pending) { + batch.getCron = pending.getCron; + batch.waiters.push(...pending.waiters); + } + } + } + } + + if (this.current === batch) { + this.current = null; + } + } + } finally { + this.current = null; + // No await separates the empty-queue observation from this assignment, + // so a later trigger either joined the loop or starts a fresh worker. + this.running = false; + } + } + + private isActiveEpoch(epoch: number): boolean { + return this.activeEpoch === epoch; + } + + private assertActiveEpoch(epoch: number): void { + if (!this.isActiveEpoch(epoch)) { + throw this.cancellationFor(epoch); + } + } + + private cancellationFor(epoch: number) { + return new StewardAutomationReconciliationCancelledError( + epoch, + this.activeEpoch === null ? 'gateway-stop' : 'gateway-restart' + ); + } + + private takePending(): PendingReconciliation | null { + const pending = this.pending; + this.pending = null; + return pending; + } + + private resolveBatch(batch: PendingReconciliation): void { + if (batch.settled) { + return; + } + batch.settled = true; + for (const waiter of batch.waiters) { + waiter.resolve(); + } + } + + private rejectBatch(batch: PendingReconciliation, error: unknown): void { + if (batch.settled) { + return; + } + batch.settled = true; + for (const waiter of batch.waiters) { + waiter.reject(error); + } + } +} + +const reconcilerSlot = sharedSlot( + 'stewardAutomation.reconciler' +); + +export function getStewardAutomationReconciler(): StewardAutomationReconciler | null { + return reconcilerSlot.get(); +} + +export function setStewardAutomationReconciler( + reconciler: StewardAutomationReconciler | null +): void { + reconcilerSlot.set(reconciler); +} + +export function isStewardAutomationProjectionEligible( + cfg: OpenClawConfig +): boolean { + return listRunnableTlonAccountIds(cfg).length === 1; +} + +export interface RegisterStewardAutomationReconciliationHooksOptions { + logger: { warn: (message: string) => void }; + getConfig: () => OpenClawConfig; +} + +function isExpectedCancellation(error: unknown): boolean { + if (error instanceof StewardAutomationReconciliationCancelledError) { + return true; + } + // The shared reconciler can originate in another plugin module-loader + // context, where instanceof observes a different copy of this class. + if (typeof error !== 'object' || error === null) { + return false; + } + const candidate = error as { + name?: unknown; + reason?: unknown; + retryable?: unknown; + }; + return ( + candidate.name === 'StewardAutomationReconciliationCancelledError' && + candidate.retryable === false && + (candidate.reason === 'gateway-stop' || + candidate.reason === 'gateway-restart') + ); +} + +function observeProjectionWork( + work: Promise, + logger: RegisterStewardAutomationReconciliationHooksOptions['logger'] +): void { + void work.catch((error) => { + if (isExpectedCancellation(error)) { + return; + } + try { + logger.warn( + `[tlon] Steward automation projection failed: ${String(error)}` + ); + } catch { + // A host logger failure must not turn this rejection observer into a new + // unhandled rejection or interfere with another hook consumer. + } + }); +} + +/** + * Bind the current registration pass to one process-lifetime reconciler. + * Hook handlers deliberately return void so projection retries and terminal + * failures cannot block or reject OpenClaw's independent hook consumers. + */ +export function registerStewardAutomationReconciliationHooks( + api: Pick, + options: RegisterStewardAutomationReconciliationHooksOptions +): StewardAutomationReconciler { + let reconciler = getStewardAutomationReconciler(); + if (!reconciler) { + reconciler = new StewardAutomationReconciler(); + setStewardAutomationReconciler(reconciler); + } + + let reportedIneligibleAccountCount: number | null = null; + const warnSafely = (message: string): void => { + try { + options.logger.warn(message); + } catch { + // A host logger failure must not bypass the account-safety guard. + } + }; + const guardSingleAccount = (): boolean => { + let config: OpenClawConfig; + try { + config = options.getConfig(); + } catch (error) { + reconciler.stop(); + warnSafely( + `[tlon] Steward automation projection disabled: current Tlon ` + + `account configuration is unavailable: ${String(error)}` + ); + return false; + } + if (isStewardAutomationProjectionEligible(config)) { + reportedIneligibleAccountCount = null; + return true; + } + const accountCount = listRunnableTlonAccountIds(config).length; + + // The connection slot is process-global, so no ship can be selected + // safely when several account monitors can publish into it. Fail closed + // and stop any epoch that began under an earlier one-account config. + reconciler.stop(); + if (accountCount > 1 && reportedIneligibleAccountCount !== accountCount) { + reportedIneligibleAccountCount = accountCount; + warnSafely( + `[tlon] Steward automation projection disabled: ${accountCount} ` + + 'runnable Tlon accounts are configured; v1 requires exactly one' + ); + } + return false; + }; + + api.on('gateway_start', (_event, ctx) => { + if (guardSingleAccount()) { + observeProjectionWork(reconciler.start(ctx.getCron), options.logger); + } + }); + api.on('cron_changed', (_event, ctx) => { + if (guardSingleAccount()) { + observeProjectionWork(reconciler.trigger(ctx.getCron), options.logger); + } + }); + api.on('gateway_stop', () => { + reconciler.stop(); + }); + return reconciler; +}