From 9cfc75e534e3663a5c53ce506a6b0505795f855a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Thu, 6 Aug 2026 08:43:36 +0800 Subject: [PATCH 01/62] Cleanup stray md file --- TLON-6042-internal-bot-rollout-path.md | 37 -------------------------- 1 file changed, 37 deletions(-) delete mode 100644 TLON-6042-internal-bot-rollout-path.md 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. From 0d1acbf957f510567576857f364859154deb4a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Thu, 6 Aug 2026 11:00:01 +0800 Subject: [PATCH 02/62] docs: reorganize; add tools folder --- docs/backend/README.md | 3 ++- docs/{ => backend/desk/app}/steward.md | 0 docs/backend/tools/README.md | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) rename docs/{ => backend/desk/app}/steward.md (100%) create mode 100644 docs/backend/tools/README.md 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/steward.md b/docs/backend/desk/app/steward.md similarity index 100% rename from docs/steward.md rename to docs/backend/desk/app/steward.md diff --git a/docs/backend/tools/README.md b/docs/backend/tools/README.md new file mode 100644 index 0000000000..5eef4bc5b6 --- /dev/null +++ b/docs/backend/tools/README.md @@ -0,0 +1,4 @@ +The following tools are available to aid developing on urbit: +`backend/run-tests.sh` - run the whole suite of backend tests: unit tests and aqua tests +`backend/update-pill.sh` - generate a new pill containing specified base and groups desks +`backend/gen-moon.sh` - generate a new moon From 70543e1d332558781ce604309dc7425ca2d3075e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Thu, 6 Aug 2026 11:00:41 +0800 Subject: [PATCH 03/62] backend: add script to generate a moon over HTTP --- backend/gen-moon.sh | 334 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100755 backend/gen-moon.sh diff --git a/backend/gen-moon.sh b/backend/gen-moon.sh new file mode 100755 index 0000000000..ad4dc7eec6 --- /dev/null +++ b/backend/gen-moon.sh @@ -0,0 +1,334 @@ +#!/bin/bash + +set -eu + +# Always run in ./backend so the cookie cache has a predictable location. +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 + -t use Tlon hosted mode (tlon.network instead of arvo.network) +EOF +} + +boot=false +hosted=false + +while getopts ":bt" opt +do + case "$opt" in + b) + boot=true + ;; + 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 [[ ! $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 (( $# != 1 )) + then + fatal "boot_moon(): expected the gen-moon JSON response" + 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 + + 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#\~} + if [[ -e $moon_name ]] + then + fatal "Cannot boot $moon_id: $moon_name 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_name/" >&2 + if ! $vere -w "$moon_name" -k "$key_file" -c "$moon_name" + 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" +fi From 5d3aef7740666c688390cfa6d239a09d15f0a1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Thu, 6 Aug 2026 11:09:19 +0800 Subject: [PATCH 04/62] AGENTS.md: add section about backend tools --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e510556c46..aa03d78714 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,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/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 From 6f654ddc4af1b2fb0e79f782614ae76def2e832e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Thu, 6 Aug 2026 11:09:31 +0800 Subject: [PATCH 05/62] Bump .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 277d7b4d9c..7054eb6db4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ packages/tlon-skill/bin/tlon *.xst zod* bud +/backend/.cookie-*.txt vere-* *.pill /rube/zod/ From 467791c10f27e5796b0b7847b901726c12b58af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Thu, 6 Aug 2026 11:15:17 +0800 Subject: [PATCH 06/62] desk: add -gen-moon thread --- desk/ted/gen-moon.hoon | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 desk/ted/gen-moon.hoon 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)) From 132231df757ee02539084f595da914335b5afd21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Fri, 7 Aug 2026 13:40:37 +0800 Subject: [PATCH 07/62] steward: scaffold automation projection --- desk/app/steward.hoon | 5 + desk/lib/steward/automation.hoon | 32 ++++++ desk/sur/steward/automation.hoon | 131 +++++++++++++++++++++++++ desk/tests/lib/steward-automation.hoon | 41 ++++++++ packages/openclaw/index.ts | 5 + 5 files changed, 214 insertions(+) create mode 100644 desk/lib/steward/automation.hoon create mode 100644 desk/sur/steward/automation.hoon create mode 100644 desk/tests/lib/steward-automation.hoon diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index 1946e6b192..ae67c2ec7c 100644 --- a/desk/app/steward.hoon +++ b/desk/app/steward.hoon @@ -625,4 +625,9 @@ (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 +:: +++ au-core + |% + -- -- diff --git a/desk/lib/steward/automation.hoon b/desk/lib/steward/automation.hoon new file mode 100644 index 0000000000..8c3d722ce2 --- /dev/null +++ b/desk/lib/steward/automation.hoon @@ -0,0 +1,32 @@ +:: Time conversions for the steward automation protocol. +:: +:: OpenClaw represents absolute dates and durations as integer milliseconds. +:: These wrappers use the standard conversions supplied by zuse. +:: +=* z ..zuse +|% +:: +milliseconds-to-duration: integer milliseconds to an Urbit duration. +:: +++ milliseconds-to-duration + |= milliseconds=@ud + ^- @dr + `@dr`(mul milliseconds (div ~s1 1.000)) +:: +duration-to-milliseconds: an Urbit duration to integer milliseconds. +:: +++ duration-to-milliseconds + |= duration=@dr + ^- @ud + (msec:(milly:z *@da) duration) +:: +unix-milliseconds-to-date: Unix epoch milliseconds to an Urbit date. +:: +++ unix-milliseconds-to-date + |= milliseconds=@ud + ^- @da + (from-unix-ms:chrono:userlib:z milliseconds) +:: +date-to-unix-milliseconds: an Urbit date to Unix epoch milliseconds. +:: +++ date-to-unix-milliseconds + |= date=@da + ^- @ud + (unm:chrono:userlib:z date) +-- diff --git a/desk/sur/steward/automation.hoon b/desk/sur/steward/automation.hoon new file mode 100644 index 0000000000..bd208420db --- /dev/null +++ b/desk/sur/steward/automation.hoon @@ -0,0 +1,131 @@ +:: steward automation module: scheduled tasks +|% +::type PluginHookGatewayCronRunStatus = "ok" | "error" | "skipped"; ++$ cron-run-status ?(%ok %error %skipped) +::type PluginHookGatewayCronDeliveryStatus = "not-requested" | "delivered" | "not-delivered" | "unknown"; ++$ cron-delivery-status + ?(%not-requested %delivered %not-delivered %unknown) +::type PluginHookGatewayCronJobState = { +:: nextRunAtMs?: number; +:: runningAtMs?: number; +:: lastRunAtMs?: number; +:: lastRunStatus?: PluginHookGatewayCronRunStatus; +:: lastError?: string; +:: lastDurationMs?: number; +:: lastDelivered?: boolean; +:: lastDeliveryStatus?: PluginHookGatewayCronDeliveryStatus; +:: lastDeliveryError?: string; +:: lastFailureNotificationDelivered?: boolean; +:: lastFailureNotificationDeliveryStatus?: PluginHookGatewayCronDeliveryStatus; +:: lastFailureNotificationDeliveryError?: string; +::}; ++$ cron-job-state + $: next-run-at=(unit @da) + running-at=(unit @da) + last-run-at=(unit @da) + last-run-status=(unit cron-run-status) + last-error=(unit @t) + last-duration=(unit @dr) + last-delivered=(unit ?) + last-delivery-status=(unit cron-delivery-status) + last-delivery-error=(unit @t) + last-failure-notification-delivered=(unit ?) + last-failure-notification-delivery-status=(unit cron-delivery-status) + last-failure-notification-delivery-error=(unit @t) + == +::type PluginHookGatewayCronJob = { +:: id: string; /** Agent id that owns this cron job. */ +:: agentId?: string; +:: name?: string; +:: description?: string; +:: enabled?: boolean; +:: schedule?: { +:: kind: "cron"; +:: expr?: string; +:: tz?: string; +:: staggerMs?: number; +:: } | { +:: kind: "at"; +:: at?: string; +:: } | { +:: kind: "every"; +:: everyMs?: number; +:: anchorMs?: number; +:: }; +:: sessionTarget?: string; +:: wakeMode?: string; +:: payload?: { +:: kind?: string; +:: text?: string; +:: }; +:: state?: PluginHookGatewayCronJobState; +:: createdAtMs?: number; +:: updatedAtMs?: number; +::}; ++$ cron-schedule + $% [%cron expr=(unit @t) tz=(unit @t) stagger=(unit @dr)] + [%at at=(unit @da)] + [%every every=(unit @dr) anchor=(unit @da)] + == ++$ cron-payload + $: kind=(unit @t) + text=(unit @t) + == ++$ cron-job + $: id=@t + 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 cron-payload) + state=(unit cron-job-state) + created-at=(unit @da) + updated-at=(unit @da) + == +::type PluginHookCronChangedEvent = { +:: action: "added" | "updated" | "removed" | "started" | "finished"; +:: jobId: string; +:: job?: PluginHookGatewayCronJob; /** Top-level session target for downstream routing (mirrors job.sessionTarget). */ +:: sessionTarget?: string; /** Agent id that owns this cron job (mirrors job.agentId). */ +:: agentId?: string; +:: runAtMs?: number; +:: durationMs?: number; +:: status?: PluginHookGatewayCronRunStatus; +:: error?: string; +:: summary?: string; +:: delivered?: boolean; +:: deliveryStatus?: PluginHookGatewayCronDeliveryStatus; +:: deliveryError?: string; +:: sessionId?: string; +:: sessionKey?: string; +:: runId?: string; +:: nextRunAtMs?: number; +:: model?: string; +:: provider?: string; +::}; ++$ cron-changed-event + $: action=?(%added %updated %removed %started %finished) + job-id=@t + job=(unit cron-job) + session-target=(unit @t) + agent-id=(unit @t) + run-at=(unit @da) + duration=(unit @dr) + status=(unit cron-run-status) + error=(unit @t) + summary=(unit @t) + delivered=(unit ?) + delivery-status=(unit cron-delivery-status) + delivery-error=(unit @t) + session-id=(unit @t) + session-key=(unit @t) + run-id=(unit @t) + next-run-at=(unit @da) + model=(unit @t) + provider=(unit @t) + == +++ v1 . +-- diff --git a/desk/tests/lib/steward-automation.hoon b/desk/tests/lib/steward-automation.hoon new file mode 100644 index 0000000000..1efb3d8038 --- /dev/null +++ b/desk/tests/lib/steward-automation.hoon @@ -0,0 +1,41 @@ +:: steward automation time conversion tests +:: +/+ *test, au=steward-automation +|% +++ test-unix-epoch-to-date + %+ expect-eq + !>(~1970.1.1) + !>((unix-milliseconds-to-date:au 0)) +:: +++ test-date-to-unix-epoch + %+ expect-eq + !>(`@ud`0) + !>((date-to-unix-milliseconds:au ~1970.1.1)) +:: +++ test-known-unix-date + ;: weld + %+ 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 ~2024.1.1)) + == +:: +++ test-milliseconds-to-duration + ;: weld + %+ expect-eq + !>(`@dr`~s1) + !>((milliseconds-to-duration:au 1.000)) + :: + %+ expect-eq + !>(`@ud`1.000) + !>((duration-to-milliseconds:au ~s1)) + == +:: +++ test-millisecond-duration-roundtrip + %+ expect-eq + !>(`@ud`1) + !> (duration-to-milliseconds:au (milliseconds-to-duration:au 1)) +-- diff --git a/packages/openclaw/index.ts b/packages/openclaw/index.ts index aca52dc12b..b7e9d80acd 100644 --- a/packages/openclaw/index.ts +++ b/packages/openclaw/index.ts @@ -1366,6 +1366,11 @@ export default defineBundledChannelEntry({ } }); + api.on('cron_changed', async (event, ctx) => { + api.logger.info('[steward auto] testing'); + api.logger.info(`[steward auto] ${JSON.stringify(event, null, 2)}`); + }); + if (shouldInstallTlonDiagnosticSubscriptions(api.registrationMode)) { const unsubscribeDiagnosticEvents = installTelemetryDiagnosticObservers(api); From 555a5119aa91605cf5d6e6c1e93406aa5e4d5878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Fri, 7 Aug 2026 13:40:48 +0800 Subject: [PATCH 08/62] openspec: plan steward automation projection --- .../.openspec.yaml | 2 + .../design.md | 97 ++++++++++ .../proposal.md | 32 ++++ .../steward-automation-projection/spec.md | 166 ++++++++++++++++++ .../tasks.md | 41 +++++ openspec/config.yaml | 32 ++++ 6 files changed, 370 insertions(+) create mode 100644 openspec/changes/mirror-openclaw-automations-to-steward/.openspec.yaml create mode 100644 openspec/changes/mirror-openclaw-automations-to-steward/design.md create mode 100644 openspec/changes/mirror-openclaw-automations-to-steward/proposal.md create mode 100644 openspec/changes/mirror-openclaw-automations-to-steward/specs/steward-automation-projection/spec.md create mode 100644 openspec/changes/mirror-openclaw-automations-to-steward/tasks.md create mode 100644 openspec/config.yaml 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..4a6b96ddcc --- /dev/null +++ b/openspec/changes/mirror-openclaw-automations-to-steward/design.md @@ -0,0 +1,97 @@ +## 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` contract. 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. Use complete replacement snapshots rather than event deltas + +The OpenClaw harness will submit the complete current task-definition set, and `%steward` will replace its automation task map atomically. Task IDs provide stable map keys, and an empty successful snapshot represents no configured tasks. + +This makes startup and later cron events use the same repair path. It also 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 cover the supported OpenClaw task-definition 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 contract and prevents execution tracking from entering scope implicitly. + +**Alternative considered:** Storing opaque OpenClaw JSON would reduce conversion work but would weaken validation, obscure compatibility changes, and couple the backend to unrelated runtime fields. + +### 5. Separate write and read contracts + +The automation module will use an independently versioned action contract for complete snapshot replacement and a separate update contract for the JSON scry. Separating inbound commands from outbound representations allows either side to evolve without overloading one mark family. + +The OpenClaw harness is the submitting actor. `%steward` does not authenticate a distinct harness identity in this increment; it authorizes the submission through the existing local Gall source boundary and rejects foreign sources. The scry uses the same local boundary. + +**Alternative considered:** Reusing one contract for both directions would conflate command validation with read representation and make later update variants harder to introduce. + +### 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. + +## 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 contract]** → Reject unsupported snapshots without replacing 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. Deploy the new Steward state version, migration, automation contracts, storage, and scry before or together with the harness projection. +2. Enable the harness projection; its first successful startup reconciliation populates the initially empty automation slice. +3. To roll back the harness integration, disable projection and leave the last Steward snapshot intact; OpenClaw remains authoritative. +4. Do not install the old Steward binary over the new state shape. A backend rollback must retain compatibility with the new state and preserve all pre-existing slices. 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..869097ae43 --- /dev/null +++ b/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md @@ -0,0 +1,32 @@ +## 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 a local, independently versioned automation action mark that atomically replaces the complete stored task projection. +- After OpenClaw `gateway_start`, have the OpenClaw harness read all jobs, including disabled jobs, and replace the `%steward` projection. +- Treat `cron_changed` events as reconciliation triggers: have the harness reread and submit the complete current job list 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. +- 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 local `%steward` scry that returns the complete stored task projection through JSON conversion. +- 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, a new automation protocol and mark, JSON conversion, local 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, and scry contracts. +- 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..1e053cab34 --- /dev/null +++ b/openspec/changes/mirror-openclaw-automations-to-steward/specs/steward-automation-projection/spec.md @@ -0,0 +1,166 @@ +## 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 and submit the complete current task-definition set, including disabled tasks, to the bot's local `%steward`. 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` + +#### 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 replaces the `%steward` projection 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 `%steward` 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 complete snapshot +- **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: 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 atomically stores the current task set + +The local `%steward` 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 replace the previously stored task set, keyed by OpenClaw task ID. Repeating an equivalent snapshot SHALL leave the same stored result. + +#### Scenario: Complete snapshot is accepted + +- **WHEN** the local OpenClaw harness submits a valid complete task snapshot 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 an empty complete snapshot 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 task snapshot 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 an automation snapshot +- **THEN** `%steward` rejects it without changing stored tasks + +### Requirement: Task definitions preserve supported OpenClaw fields + +Each stored task SHALL preserve its ID and the definition fields supplied by OpenClaw for agent ownership, display metadata, enabled state, schedule, session target, wake mode, payload, and creation/update timestamps when those fields are present. 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 task and JSON representation preserve those fields and their absence/presence semantics + +#### 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: Local JSON task scry + +`%steward` SHALL expose a local-only scry at `/x/v1/automation/tasks` that returns the complete currently stored task projection as JSON. The response SHALL contain a `tasks` array, and each task SHALL use the supported OpenClaw field names and JSON value shapes while omitting cron job state. + +#### Scenario: Stored tasks are read + +- **WHEN** a local client scries `/x/v1/automation/tasks` after a snapshot has been accepted +- **THEN** it receives a JSON object containing every currently stored task exactly once in the `tasks` array + +#### Scenario: No tasks are stored + +- **WHEN** a local client scries `/x/v1/automation/tasks` while the projection is empty +- **THEN** it receives `{ "tasks": [] }` + +#### Scenario: Foreign client attempts to read tasks + +- **WHEN** a non-local source attempts the automation task scry +- **THEN** `%steward` rejects the request 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..d4971b9c2b --- /dev/null +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -0,0 +1,41 @@ +## 1. Automation Contract and Conversions + +- [ ] 1.1 Finalize the v1 automation types for supported task definitions, automation state, complete replacement actions, and task-list updates, excluding cron job state and execution events. +- [ ] 1.2 Complete the Hoon millisecond, date, and duration conversions required by supported schedule and timestamp fields. +- [ ] 1.3 Extend conversion tests with boundary, round-trip, optional-field, and supported-schedule cases. + +## 2. Steward State, Storage, and JSON API + +- [ ] 2.1 Introduce a new Steward state version and migrate released state while preserving populated core, trusted-bot, lens, and gateway values and initializing automation empty. +- [ ] 2.2 Add migration tests for populated released state, fresh initialization, persistence, and visible failure instead of silent reset. +- [ ] 2.3 Implement atomic complete replacement keyed by task ID, including empty and repeated snapshots, removal by omission, duplicate-ID rejection, and unchanged state after invalid input. +- [ ] 2.4 Enforce the local Gall source boundary for automation replacement and reject foreign sources without changing state. +- [ ] 2.5 Add the versioned automation action and update marks with validated JSON/noun conversion and no cron job state. +- [ ] 2.6 Add the local `/x/v1/automation/tasks` scry with deterministic task ordering and `{ "tasks": [] }` for empty state. +- [ ] 2.7 Extend Steward tests for replacement, access control, supported task fields, persistence, JSON conversion, and scry behavior. + +## 3. OpenClaw Harness Projection + +- [ ] 3.1 Add task normalization that preserves supported definition fields, omits execution state, and produces complete Steward replacement payloads. +- [ ] 3.2 Add a local Steward submission adapter using the monitor-published ship connection and successful poke acknowledgement. +- [ ] 3.3 Trigger complete reads with disabled tasks included after `gateway_start` and every `cron_changed` event using the pinned OpenClaw cron access. +- [ ] 3.4 Serialize reconciliation, coalesce triggers received while busy into one follow-up, and prevent snapshots from overtaking one another. +- [ ] 3.5 Retry unavailable cron reads and failed Steward submissions while the gateway remains active, preserving the last successful projection. +- [ ] 3.6 Stop new reconciliation and retry activity on `gateway_stop` without clearing the durable Steward snapshot. +- [ ] 3.7 Replace the temporary diagnostic handler with projection registration while keeping cron telemetry failures isolated. + +## 4. Projection Verification + +- [ ] 4.1 Test normalization of optional fields and all supported schedules, inclusion of disabled tasks, and omission of cron job state. +- [ ] 4.2 Test startup reconciliation when cron access is ready, temporarily unavailable, empty, and restored after a stale period. +- [ ] 4.3 Test complete rereads after definition-related and execution-related `cron_changed` events. +- [ ] 4.4 Test serialized delivery, coalesced triggers, trigger arrival during submission, and the worker-exit race. +- [ ] 4.5 Test read failures, submission failures, retry behavior, acknowledgement failures, and gateway shutdown. +- [ ] 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 + +- [ ] 5.1 Document the Steward automation state, migration, local harness replacement action, best-effort OpenClaw flow, exclusions, and JSON scry. +- [ ] 5.2 Run the targeted Hoon tests, applicable backend suite, and desk compilation on the development ship. +- [ ] 5.3 Run OpenClaw formatting, linting, type checking, unit tests, and relevant integration tests against the existing pinned runtime. +- [ ] 5.4 Run strict OpenSpec validation and verify implementation coverage for every capability scenario. 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 From e5d366f10a083d96f227d613305dbfef7ecedb67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Sat, 8 Aug 2026 07:08:20 +0800 Subject: [PATCH 09/62] docs: define backend development workflow --- AGENTS.md | 34 ++++++++++++++++++++++++- docs/backend/tools/README.md | 49 +++++++++++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa03d78714..2d259be238 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,24 @@ 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 @@ -25,7 +43,7 @@ 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/tools`. +Documentation can be found in `/docs/backend/tools`. ## Backend tests There are two kinds of backend tests in groups. The first kind uses the @@ -48,3 +66,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/docs/backend/tools/README.md b/docs/backend/tools/README.md index 5eef4bc5b6..6a1b4b2e7b 100644 --- a/docs/backend/tools/README.md +++ b/docs/backend/tools/README.md @@ -1,4 +1,45 @@ -The following tools are available to aid developing on urbit: -`backend/run-tests.sh` - run the whole suite of backend tests: unit tests and aqua tests -`backend/update-pill.sh` - generate a new pill containing specified base and groups desks -`backend/gen-moon.sh` - generate a new moon +# 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. From b96ec913e943690c7c152bc1e3b97ff461d5b863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Sat, 8 Aug 2026 07:08:28 +0800 Subject: [PATCH 10/62] openspec: name automation project action --- .../design.md | 10 ++++----- .../proposal.md | 6 ++--- .../steward-automation-projection/spec.md | 22 +++++++++---------- .../tasks.md | 16 +++++++------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/design.md b/openspec/changes/mirror-openclaw-automations-to-steward/design.md index 4a6b96ddcc..b9703f1cbe 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/design.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/design.md @@ -24,11 +24,11 @@ The pinned OpenClaw version provides `gateway_start`, `cron_changed`, `gateway_s ## Decisions -### 1. Use complete replacement snapshots rather than event deltas +### 1. Use `%project` to atomically commit complete snapshots rather than event deltas -The OpenClaw harness will submit the complete current task-definition set, and `%steward` will replace its automation task map atomically. Task IDs provide stable map keys, and an empty successful snapshot represents no configured tasks. +The OpenClaw harness will submit the complete current task-definition set through `%project`, and `%steward` will validate and commit the entire automation 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. -This makes startup and later cron events use the same repair path. It also makes repeated submissions idempotent and removes tasks that disappeared while the integration was unavailable. +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. @@ -58,7 +58,7 @@ A typed model gives `%steward` a versioned, validated contract and prevents exec ### 5. Separate write and read contracts -The automation module will use an independently versioned action contract for complete snapshot replacement and a separate update contract for the JSON scry. Separating inbound commands from outbound representations allows either side to evolve without overloading one mark family. +The automation module will use an independently versioned `%project` action contract for complete projection commits and a separate update contract for the JSON scry. Separating inbound commands from outbound representations allows either side to evolve without overloading one mark family. The OpenClaw harness is the submitting actor. `%steward` does not authenticate a distinct harness identity in this increment; it authorizes the submission through the existing local Gall source boundary and rejects foreign sources. The scry uses the same local boundary. @@ -85,7 +85,7 @@ This retains the current operational observer while keeping the new durable proj - **[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 contract]** → Reject unsupported snapshots without replacing the last known-good projection, then add a later protocol version. +- **[Future OpenClaw task variants may not fit the v1 contract]** → 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. diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md b/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md index 869097ae43..f2868e331c 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md @@ -6,9 +6,9 @@ OpenClaw currently keeps automation definitions inside the external harness, so - 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 a local, independently versioned automation action mark that atomically replaces the complete stored task projection. -- After OpenClaw `gateway_start`, have the OpenClaw harness read all jobs, including disabled jobs, and replace the `%steward` projection. -- Treat `cron_changed` events as reconciliation triggers: have the harness reread and submit the complete current job list rather than applying event payloads as deltas. +- Add a local, independently versioned `%project` automation action that 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. - 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 local `%steward` scry that returns the complete stored task projection through JSON conversion. 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 index 1e053cab34..24d6cc0bd8 100644 --- 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 @@ -6,12 +6,12 @@ Provide the bot ship with a durable, locally readable, best-effort mirror of com ### Requirement: Gateway startup triggers a complete task read -After `gateway_start`, the OpenClaw harness SHALL read and submit the complete current task-definition set, including disabled tasks, to the bot's local `%steward`. The submitted definitions SHALL exclude cron job `state` and execution events. +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` +- **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 @@ -30,7 +30,7 @@ The OpenClaw harness SHALL treat every `cron_changed` event as a reconciliation #### Scenario: Cron change occurs - **WHEN** OpenClaw emits `cron_changed` -- **THEN** the harness reads the complete current task set, including disabled tasks, and replaces the `%steward` projection rather than applying the event payload directly +- **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 @@ -58,7 +58,7 @@ The OpenClaw harness SHALL allow at most one complete read-and-submit operation ### Requirement: Failed reconciliation is retried -A failed complete read or `%steward` 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. +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 @@ -67,7 +67,7 @@ A failed complete read or `%steward` delivery SHALL NOT clear the last successfu #### Scenario: Steward delivery fails -- **WHEN** `%steward` does not accept a complete snapshot +- **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 @@ -89,28 +89,28 @@ The `%steward` automation state SHALL represent the latest complete OpenClaw tas - **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 atomically stores the current task set +### Requirement: Steward atomically commits the current task projection -The local `%steward` 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 replace the previously stored task set, keyed by OpenClaw task ID. Repeating an equivalent snapshot SHALL leave the same stored result. +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 complete task snapshot through the local ship source +- **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 an empty complete snapshot through the local ship source +- **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 task snapshot more than once through the local ship source +- **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 an automation 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 diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index d4971b9c2b..8d529aff22 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -1,6 +1,6 @@ ## 1. Automation Contract and Conversions -- [ ] 1.1 Finalize the v1 automation types for supported task definitions, automation state, complete replacement actions, and task-list updates, excluding cron job state and execution events. +- [ ] 1.1 Finalize the v1 automation types for supported task definitions, automation state, complete `%project` actions, and task-list updates, excluding cron job state and execution events. - [ ] 1.2 Complete the Hoon millisecond, date, and duration conversions required by supported schedule and timestamp fields. - [ ] 1.3 Extend conversion tests with boundary, round-trip, optional-field, and supported-schedule cases. @@ -8,16 +8,16 @@ - [ ] 2.1 Introduce a new Steward state version and migrate released state while preserving populated core, trusted-bot, lens, and gateway values and initializing automation empty. - [ ] 2.2 Add migration tests for populated released state, fresh initialization, persistence, and visible failure instead of silent reset. -- [ ] 2.3 Implement atomic complete replacement keyed by task ID, including empty and repeated snapshots, removal by omission, duplicate-ID rejection, and unchanged state after invalid input. -- [ ] 2.4 Enforce the local Gall source boundary for automation replacement and reject foreign sources without changing state. -- [ ] 2.5 Add the versioned automation action and update marks with validated JSON/noun conversion and no cron job state. +- [ ] 2.3 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. +- [ ] 2.4 Enforce the local Gall source boundary for `%project` and reject foreign sources without changing state. +- [ ] 2.5 Add the versioned automation action and update marks with validated `%project` JSON/noun conversion and no cron job state. - [ ] 2.6 Add the local `/x/v1/automation/tasks` scry with deterministic task ordering and `{ "tasks": [] }` for empty state. -- [ ] 2.7 Extend Steward tests for replacement, access control, supported task fields, persistence, JSON conversion, and scry behavior. +- [ ] 2.7 Extend Steward tests for populated, empty, repeated, invalid, and foreign `%project` submissions, supported task fields, persistence, JSON conversion, and scry behavior. ## 3. OpenClaw Harness Projection -- [ ] 3.1 Add task normalization that preserves supported definition fields, omits execution state, and produces complete Steward replacement payloads. -- [ ] 3.2 Add a local Steward submission adapter using the monitor-published ship connection and successful poke acknowledgement. +- [ ] 3.1 Add task normalization that preserves supported definition fields, omits execution state, and produces complete Steward `%project` payloads. +- [ ] 3.2 Add a local Steward adapter that submits `%project` through the monitor-published ship connection and requires successful poke acknowledgement. - [ ] 3.3 Trigger complete reads with disabled tasks included after `gateway_start` and every `cron_changed` event using the pinned OpenClaw cron access. - [ ] 3.4 Serialize reconciliation, coalesce triggers received while busy into one follow-up, and prevent snapshots from overtaking one another. - [ ] 3.5 Retry unavailable cron reads and failed Steward submissions while the gateway remains active, preserving the last successful projection. @@ -35,7 +35,7 @@ ## 5. Documentation and Validation -- [ ] 5.1 Document the Steward automation state, migration, local harness replacement action, best-effort OpenClaw flow, exclusions, and JSON scry. +- [ ] 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. - [ ] 5.2 Run the targeted Hoon tests, applicable backend suite, and desk compilation on the development ship. - [ ] 5.3 Run OpenClaw formatting, linting, type checking, unit tests, and relevant integration tests against the existing pinned runtime. - [ ] 5.4 Run strict OpenSpec validation and verify implementation coverage for every capability scenario. From 6a9cf468592b52d4d843c7e7a45001766df0a98f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Sat, 8 Aug 2026 07:08:43 +0800 Subject: [PATCH 11/62] steward: define automation projection contract --- desk/sur/steward/automation.hoon | 133 ++++-------------- .../tasks.md | 2 +- 2 files changed, 29 insertions(+), 106 deletions(-) diff --git a/desk/sur/steward/automation.hoon b/desk/sur/steward/automation.hoon index bd208420db..2777a58b01 100644 --- a/desk/sur/steward/automation.hoon +++ b/desk/sur/steward/automation.hoon @@ -1,76 +1,25 @@ -:: steward automation module: scheduled tasks +:: steward automation module: mirrored OpenClaw task definitions +:: |% -::type PluginHookGatewayCronRunStatus = "ok" | "error" | "skipped"; -+$ cron-run-status ?(%ok %error %skipped) -::type PluginHookGatewayCronDeliveryStatus = "not-requested" | "delivered" | "not-delivered" | "unknown"; -+$ cron-delivery-status - ?(%not-requested %delivered %not-delivered %unknown) -::type PluginHookGatewayCronJobState = { -:: nextRunAtMs?: number; -:: runningAtMs?: number; -:: lastRunAtMs?: number; -:: lastRunStatus?: PluginHookGatewayCronRunStatus; -:: lastError?: string; -:: lastDurationMs?: number; -:: lastDelivered?: boolean; -:: lastDeliveryStatus?: PluginHookGatewayCronDeliveryStatus; -:: lastDeliveryError?: string; -:: lastFailureNotificationDelivered?: boolean; -:: lastFailureNotificationDeliveryStatus?: PluginHookGatewayCronDeliveryStatus; -:: lastFailureNotificationDeliveryError?: string; -::}; -+$ cron-job-state - $: next-run-at=(unit @da) - running-at=(unit @da) - last-run-at=(unit @da) - last-run-status=(unit cron-run-status) - last-error=(unit @t) - last-duration=(unit @dr) - last-delivered=(unit ?) - last-delivery-status=(unit cron-delivery-status) - last-delivery-error=(unit @t) - last-failure-notification-delivered=(unit ?) - last-failure-notification-delivery-status=(unit cron-delivery-status) - last-failure-notification-delivery-error=(unit @t) - == -::type PluginHookGatewayCronJob = { -:: id: string; /** Agent id that owns this cron job. */ -:: agentId?: string; -:: name?: string; -:: description?: string; -:: enabled?: boolean; -:: schedule?: { -:: kind: "cron"; -:: expr?: string; -:: tz?: string; -:: staggerMs?: number; -:: } | { -:: kind: "at"; -:: at?: string; -:: } | { -:: kind: "every"; -:: everyMs?: number; -:: anchorMs?: number; -:: }; -:: sessionTarget?: string; -:: wakeMode?: string; -:: payload?: { -:: kind?: string; -:: text?: string; -:: }; -:: state?: PluginHookGatewayCronJobState; -:: createdAtMs?: number; -:: updatedAtMs?: number; -::}; +:: $cron-schedule: the supported OpenClaw schedule variants. OpenClaw uses +:: integer milliseconds at the boundary; the Hoon contract 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)] == +:: $cron-payload: the definition fields of an OpenClaw task payload. +:: +$ cron-payload $: kind=(unit @t) text=(unit @t) == +:: $cron-job: the supported definition-only subset of +:: PluginHookGatewayCronJob. Runtime job state and execution history are not +:: part of the automation protocol. +:: +$ cron-job $: id=@t agent-id=(unit @t) @@ -81,51 +30,25 @@ session-target=(unit @t) wake-mode=(unit @t) payload=(unit cron-payload) - state=(unit cron-job-state) created-at=(unit @da) updated-at=(unit @da) == -::type PluginHookCronChangedEvent = { -:: action: "added" | "updated" | "removed" | "started" | "finished"; -:: jobId: string; -:: job?: PluginHookGatewayCronJob; /** Top-level session target for downstream routing (mirrors job.sessionTarget). */ -:: sessionTarget?: string; /** Agent id that owns this cron job (mirrors job.agentId). */ -:: agentId?: string; -:: runAtMs?: number; -:: durationMs?: number; -:: status?: PluginHookGatewayCronRunStatus; -:: error?: string; -:: summary?: string; -:: delivered?: boolean; -:: deliveryStatus?: PluginHookGatewayCronDeliveryStatus; -:: deliveryError?: string; -:: sessionId?: string; -:: sessionKey?: string; -:: runId?: string; -:: nextRunAtMs?: number; -:: model?: string; -:: provider?: string; -::}; -+$ cron-changed-event - $: action=?(%added %updated %removed %started %finished) - job-id=@t - job=(unit cron-job) - session-target=(unit @t) - agent-id=(unit @t) - run-at=(unit @da) - duration=(unit @dr) - status=(unit cron-run-status) - error=(unit @t) - summary=(unit @t) - delivered=(unit ?) - delivery-status=(unit cron-delivery-status) - delivery-error=(unit @t) - session-id=(unit @t) - session-key=(unit @t) - run-id=(unit @t) - next-run-at=(unit @da) - model=(unit @t) - provider=(unit @t) +:: $state: the latest complete task projection, keyed by OpenClaw task ID. +:: ++$ state + $: tasks=(map @t cron-job) + == +:: $action: inbound automation actions from the local harness. +:: +:: %project: atomically replace the complete task projection. +:: ++$ action + $% [%project tasks=(list cron-job)] + == +:: $update: automation scry result. +:: ++$ update + $% [%tasks tasks=(list cron-job)] == ++ v1 . -- diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 8d529aff22..e6e5896678 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -1,6 +1,6 @@ ## 1. Automation Contract and Conversions -- [ ] 1.1 Finalize the v1 automation types for supported task definitions, automation state, complete `%project` actions, and task-list updates, excluding cron job state and execution events. +- [x] 1.1 Finalize the v1 automation types for supported task definitions, automation state, complete `%project` actions, and task-list updates, excluding cron job state and execution events. - [ ] 1.2 Complete the Hoon millisecond, date, and duration conversions required by supported schedule and timestamp fields. - [ ] 1.3 Extend conversion tests with boundary, round-trip, optional-field, and supported-schedule cases. From 11f55d607d8d72151d2a91ec61c0cc565c5c43e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Sat, 8 Aug 2026 07:16:58 +0800 Subject: [PATCH 12/62] steward: fix automation time conversions --- desk/lib/steward/automation.hoon | 4 ++-- .../changes/mirror-openclaw-automations-to-steward/tasks.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/desk/lib/steward/automation.hoon b/desk/lib/steward/automation.hoon index 8c3d722ce2..fd42ccf4f6 100644 --- a/desk/lib/steward/automation.hoon +++ b/desk/lib/steward/automation.hoon @@ -10,13 +10,13 @@ ++ milliseconds-to-duration |= milliseconds=@ud ^- @dr - `@dr`(mul milliseconds (div ~s1 1.000)) + `@dr`(div (mul milliseconds ~s1) 1.000) :: +duration-to-milliseconds: an Urbit duration to integer milliseconds. :: ++ duration-to-milliseconds |= duration=@dr ^- @ud - (msec:(milly:z *@da) duration) + (msec:milly:z duration) :: +unix-milliseconds-to-date: Unix epoch milliseconds to an Urbit date. :: ++ unix-milliseconds-to-date diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index e6e5896678..60d26595cf 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -1,7 +1,7 @@ ## 1. Automation Contract and Conversions - [x] 1.1 Finalize the v1 automation types for supported task definitions, automation state, complete `%project` actions, and task-list updates, excluding cron job state and execution events. -- [ ] 1.2 Complete the Hoon millisecond, date, and duration conversions required by supported schedule and timestamp fields. +- [x] 1.2 Complete the Hoon millisecond, date, and duration conversions required by supported schedule and timestamp fields. - [ ] 1.3 Extend conversion tests with boundary, round-trip, optional-field, and supported-schedule cases. ## 2. Steward State, Storage, and JSON API From c7d64b12a53e160fce1c7d72c627ce2fe3a1d670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Sat, 8 Aug 2026 07:29:05 +0800 Subject: [PATCH 13/62] steward: test automation projection contract --- desk/tests/lib/steward-automation.hoon | 120 ++++++++++++++---- .../tasks.md | 2 +- 2 files changed, 99 insertions(+), 23 deletions(-) diff --git a/desk/tests/lib/steward-automation.hoon b/desk/tests/lib/steward-automation.hoon index 1efb3d8038..4a73db5242 100644 --- a/desk/tests/lib/steward-automation.hoon +++ b/desk/tests/lib/steward-automation.hoon @@ -1,41 +1,117 @@ -:: steward automation time conversion tests +:: steward automation contract and time conversion tests :: +/- a=steward-automation /+ *test, au=steward-automation |% -++ test-unix-epoch-to-date - %+ expect-eq - !>(~1970.1.1) - !>((unix-milliseconds-to-date:au 0)) +++ populated-job + ^- cron-job:v1:a + :* 'task-1' + (some 'agent-1') + (some 'Daily summary') + (some 'Send the daily summary') + (some %.y) + (some [%cron (some '0 9 * * *') (some 'UTC') (some ~s30)]) + (some 'isolated') + (some 'now') + (some [(some 'agentTurn') (some 'Summarize activity')]) + (some ~2024.1.1) + (some ~2024.1.2) + == :: -++ test-date-to-unix-epoch - %+ expect-eq - !>(`@ud`0) - !>((date-to-unix-milliseconds:au ~1970.1.1)) +++ 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-known-unix-date +++ 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 ~2024.1.1)) + !> (date-to-unix-milliseconds:au (unix-milliseconds-to-date:au 1.704.067.200.000)) == :: -++ test-milliseconds-to-duration +++ test-v1-contract-optional-fields + =/ empty=cron-job:v1:a + :* '' + ~ + ~ + ~ + ~ + ~ + ~ + ~ + ~ + ~ + ~ + == + =/ job=cron-job:v1:a populated-job + =/ action=action:v1:a [%project ~[job]] + =/ update=update:v1:a [%tasks ~[job]] ;: weld + (expect-eq !>(empty) !>(*cron-job:v1:a)) + (expect-eq !>(`action:v1:a`[%project ~[job]]) !>(action)) + (expect-eq !>(`update:v1:a`[%tasks ~[job]]) !>(update)) + == +:: +++ test-schedule-variants-and-optional-fields + =/ empty-cron=cron-schedule:v1:a [%cron ~ ~ ~] + =/ cron=cron-schedule:v1:a + [%cron (some '0 9 * * *') (some 'UTC') (some ~s30)] + =/ empty-at=cron-schedule:v1:a [%at ~] + =/ at=cron-schedule:v1:a [%at (some ~2024.2.29..12.34.56)] + =/ empty-every=cron-schedule:v1:a [%every ~ ~] + =/ every=cron-schedule:v1:a + [%every (some ~m15) (some ~2024.1.1)] + ;: weld + (expect-eq !>(`cron-schedule:v1:a`[%cron ~ ~ ~]) !>(empty-cron)) %+ expect-eq - !>(`@dr`~s1) - !>((milliseconds-to-duration:au 1.000)) - :: + !>(`cron-schedule:v1:a`[%cron (some '0 9 * * *') (some 'UTC') (some ~s30)]) + !>(cron) + (expect-eq !>(`cron-schedule:v1:a`[%at ~]) !>(empty-at)) + %+ expect-eq + !>(`cron-schedule:v1:a`[%at (some ~2024.2.29..12.34.56)]) + !>(at) + (expect-eq !>(`cron-schedule:v1:a`[%every ~ ~]) !>(empty-every)) %+ expect-eq - !>(`@ud`1.000) - !>((duration-to-milliseconds:au ~s1)) + !>(`cron-schedule:v1:a`[%every (some ~m15) (some ~2024.1.1)]) + !>(every) == -:: -++ test-millisecond-duration-roundtrip - %+ expect-eq - !>(`@ud`1) - !> (duration-to-milliseconds:au (milliseconds-to-duration:au 1)) -- diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 60d26595cf..8c07e557f2 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -2,7 +2,7 @@ - [x] 1.1 Finalize the v1 automation types for supported task definitions, automation state, complete `%project` actions, and task-list updates, 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. -- [ ] 1.3 Extend conversion tests with boundary, round-trip, optional-field, and supported-schedule cases. +- [x] 1.3 Extend conversion tests with boundary, round-trip, optional-field, and supported-schedule cases. ## 2. Steward State, Storage, and JSON API From 84c7fb3b7f52af013dd7b131443bed813c33fd6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 11:11:56 +0800 Subject: [PATCH 14/62] openspec: schedule trace-derived projection tests --- .../changes/mirror-openclaw-automations-to-steward/design.md | 2 ++ .../changes/mirror-openclaw-automations-to-steward/tasks.md | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/design.md b/openspec/changes/mirror-openclaw-automations-to-steward/design.md index b9703f1cbe..14a18af8e5 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/design.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/design.md @@ -54,6 +54,8 @@ The automation model will cover the supported OpenClaw task-definition fields an A typed model gives `%steward` a versioned, validated contract 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 contract tests will parse normalized `%project` JSON through the production `dejs` path and serialize updates through the production `enjs` path. Hand-constructed tests will remain only for focused primitive conversion boundaries. + **Alternative considered:** Storing opaque OpenClaw JSON would reduce conversion work but would weaken validation, obscure compatibility changes, and couple the backend to unrelated runtime fields. ### 5. Separate write and read contracts diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 8c07e557f2..3b41c59508 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -12,7 +12,7 @@ - [ ] 2.4 Enforce the local Gall source boundary for `%project` and reject foreign sources without changing state. - [ ] 2.5 Add the versioned automation action and update marks with validated `%project` JSON/noun conversion and no cron job state. - [ ] 2.6 Add the local `/x/v1/automation/tasks` scry with deterministic task ordering and `{ "tasks": [] }` for empty state. -- [ ] 2.7 Extend Steward tests for populated, empty, repeated, invalid, and foreign `%project` submissions, supported task fields, persistence, JSON conversion, and scry behavior. +- [ ] 2.7 After the production JSON marks exist, replace the hand-constructed contract and schedule tests from 1.3 with tests that parse realistic normalized `%project` JSON fixtures derived from captured OpenClaw traces. Retain the focused conversion boundary tests, and cover populated, empty, repeated, invalid, and foreign submissions, persistence, update serialization, and scry behavior. ## 3. OpenClaw Harness Projection @@ -26,7 +26,7 @@ ## 4. Projection Verification -- [ ] 4.1 Test normalization of optional fields and all supported schedules, inclusion of disabled tasks, and omission of cron job state. +- [ ] 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. - [ ] 4.2 Test startup reconciliation when cron access is ready, temporarily unavailable, empty, and restored after a stale period. - [ ] 4.3 Test complete rereads after definition-related and execution-related `cron_changed` events. - [ ] 4.4 Test serialized delivery, coalesced triggers, trigger arrival during submission, and the worker-exit race. From f4f69a3ebf560f1943ee076f916480fe925a61ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 11:19:56 +0800 Subject: [PATCH 15/62] openspec: define direct task-list scry mark --- .../design.md | 16 ++++++++-------- .../proposal.md | 4 ++-- .../tasks.md | 8 ++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/design.md b/openspec/changes/mirror-openclaw-automations-to-steward/design.md index 14a18af8e5..45ee63cfcb 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/design.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/design.md @@ -4,7 +4,7 @@ See `proposal.md` for motivation and `specs/steward-automation-projection/spec.m `%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` contract. 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. +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 @@ -52,19 +52,19 @@ Serialization prevents overlapping submissions from completing out of order. Coa The automation model will cover the supported OpenClaw task-definition 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 contract and prevents execution tracking from entering scope implicitly. +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 contract tests will parse normalized `%project` JSON through the production `dejs` path and serialize updates through the production `enjs` path. Hand-constructed tests will remain only for focused primitive conversion boundaries. +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 lists through the production `enjs` path. Hand-constructed tests will remain only for focused primitive conversion boundaries. **Alternative considered:** Storing opaque OpenClaw JSON would reduce conversion work but would weaken validation, obscure compatibility changes, and couple the backend to unrelated runtime fields. -### 5. Separate write and read contracts +### 5. Use separate action and task-list marks -The automation module will use an independently versioned `%project` action contract for complete projection commits and a separate update contract for the JSON scry. Separating inbound commands from outbound representations allows either side to evolve without overloading one mark family. +The automation module will use an independently versioned `%project` action mark for complete projection commits and a dedicated task-list mark for the JSON scry. The scry mark will directly accept the ordered task list and grow it to `{ "tasks": [...] }`; automation has no subscription surface or heterogeneous scry results that would justify a tagged `$update` union. Keeping the inbound action and outbound task-list 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 the submission through the existing local Gall source boundary and rejects foreign sources. The scry uses the same local boundary. -**Alternative considered:** Reusing one contract for both directions would conflate command validation with read representation and make later update variants harder to introduce. +**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-list mark. ### 6. Add an explicit migration from the released Steward state @@ -87,13 +87,13 @@ This retains the current operational observer while keeping the new durable proj - **[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 contract]** → Reject unsupported `%project` submissions without changing the last known-good projection, then add a later protocol version. +- **[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. Deploy the new Steward state version, migration, automation contracts, storage, and scry before or together with the harness projection. +1. Deploy the new Steward state version, migration, automation types and marks, storage, and scry before or together with the harness projection. 2. Enable the harness projection; its first successful startup reconciliation populates the initially empty automation slice. 3. To roll back the harness integration, disable projection and leave the last Steward snapshot intact; OpenClaw remains authoritative. 4. Do not install the old Steward binary over the new state shape. A backend rollback must retain compatibility with the new state and preserve all pre-existing slices. diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md b/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md index f2868e331c..edb001a443 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md @@ -26,7 +26,7 @@ None. ## Impact -- Backend: a versioned `%steward` state migration, automation dispatch, a new automation protocol and mark, JSON conversion, local scry handling, and Hoon tests. +- Backend: a versioned `%steward` state migration, automation dispatch, new automation action and task-list marks, JSON conversion, local 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, and scry contracts. +- 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/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 3b41c59508..7d713eb8c4 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -1,6 +1,6 @@ -## 1. Automation Contract and Conversions +## 1. Automation Types and Conversions -- [x] 1.1 Finalize the v1 automation types for supported task definitions, automation state, complete `%project` actions, and task-list updates, excluding cron job state and execution events. +- [x] 1.1 Finalize the v1 automation types for supported task definitions, automation state, complete `%project` actions, and task-list 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. @@ -10,9 +10,9 @@ - [ ] 2.2 Add migration tests for populated released state, fresh initialization, persistence, and visible failure instead of silent reset. - [ ] 2.3 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. - [ ] 2.4 Enforce the local Gall source boundary for `%project` and reject foreign sources without changing state. -- [ ] 2.5 Add the versioned automation action and update marks with validated `%project` JSON/noun conversion and no cron job state. +- [ ] 2.5 Add the versioned automation action mark and dedicated task-list scry mark with validated `%project` JSON/noun conversion, `{ "tasks": [...] }` JSON serialization, and no cron job state. - [ ] 2.6 Add the local `/x/v1/automation/tasks` scry with deterministic task ordering and `{ "tasks": [] }` for empty state. -- [ ] 2.7 After the production JSON marks exist, replace the hand-constructed contract and schedule tests from 1.3 with tests that parse realistic normalized `%project` JSON fixtures derived from captured OpenClaw traces. Retain the focused conversion boundary tests, and cover populated, empty, repeated, invalid, and foreign submissions, persistence, update serialization, and scry behavior. +- [ ] 2.7 After the production JSON marks exist, replace the hand-constructed type and schedule tests from 1.3 with tests that parse realistic normalized `%project` JSON fixtures derived from captured OpenClaw traces. Retain the focused conversion boundary tests, and cover populated, empty, repeated, invalid, and foreign submissions, persistence, task-list serialization, and scry behavior. ## 3. OpenClaw Harness Projection From 431663caa0ddd145582d9741ffe8dd3a852fff1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 11:24:57 +0800 Subject: [PATCH 16/62] steward: use direct task-list scry type --- desk/sur/steward/automation.hoon | 10 ++++------ desk/tests/lib/steward-automation.hoon | 8 ++++---- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/desk/sur/steward/automation.hoon b/desk/sur/steward/automation.hoon index 2777a58b01..6c1c33a1e8 100644 --- a/desk/sur/steward/automation.hoon +++ b/desk/sur/steward/automation.hoon @@ -2,8 +2,8 @@ :: |% :: $cron-schedule: the supported OpenClaw schedule variants. OpenClaw uses -:: integer milliseconds at the boundary; the Hoon contract stores dates and -:: durations in their native atom types. +:: integer milliseconds at the boundary; the Hoon representation stores dates +:: and durations in their native atom types. :: +$ cron-schedule $% [%cron expr=(unit @t) tz=(unit @t) stagger=(unit @dr)] @@ -45,10 +45,8 @@ +$ action $% [%project tasks=(list cron-job)] == -:: $update: automation scry result. +:: $task-list: the ordered task list returned by the automation scry. :: -+$ update - $% [%tasks tasks=(list cron-job)] - == ++$ task-list (list cron-job) ++ v1 . -- diff --git a/desk/tests/lib/steward-automation.hoon b/desk/tests/lib/steward-automation.hoon index 4a73db5242..ce5a9f591e 100644 --- a/desk/tests/lib/steward-automation.hoon +++ b/desk/tests/lib/steward-automation.hoon @@ -1,4 +1,4 @@ -:: steward automation contract and time conversion tests +:: steward automation type and time conversion tests :: /- a=steward-automation /+ *test, au=steward-automation @@ -68,7 +68,7 @@ !> (date-to-unix-milliseconds:au (unix-milliseconds-to-date:au 1.704.067.200.000)) == :: -++ test-v1-contract-optional-fields +++ test-v1-types-and-optional-fields =/ empty=cron-job:v1:a :* '' ~ @@ -84,11 +84,11 @@ == =/ job=cron-job:v1:a populated-job =/ action=action:v1:a [%project ~[job]] - =/ update=update:v1:a [%tasks ~[job]] + =/ task-list=task-list:v1:a ~[job] ;: weld (expect-eq !>(empty) !>(*cron-job:v1:a)) (expect-eq !>(`action:v1:a`[%project ~[job]]) !>(action)) - (expect-eq !>(`update:v1:a`[%tasks ~[job]]) !>(update)) + (expect-eq !>(`task-list:v1:a`~[job]) !>(task-list)) == :: ++ test-schedule-variants-and-optional-fields From 1ff7dce4c367927bd0d0e3f8f993205014b55a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 13:06:22 +0800 Subject: [PATCH 17/62] openspec: defer migration until task API stabilizes --- .../design.md | 213 ++++++++++++++---- .../proposal.md | 63 ++++-- .../steward-automation-projection/spec.md | 179 +++++++++++---- .../tasks.md | 115 +++++++--- 4 files changed, 430 insertions(+), 140 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/design.md b/openspec/changes/mirror-openclaw-automations-to-steward/design.md index 45ee63cfcb..15061c7015 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/design.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/design.md @@ -1,19 +1,33 @@ ## 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. +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. +- 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:** @@ -24,76 +38,175 @@ The pinned OpenClaw version provides `gateway_start`, `cron_changed`, `gateway_s ## Decisions -### 1. Use `%project` to atomically commit complete snapshots rather than event deltas +### 1. Commit complete snapshots atomically with `%project` -The OpenClaw harness will submit the complete current task-definition set through `%project`, and `%steward` will validate and commit the entire automation 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 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. +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. +**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. +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. +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. +**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. +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. +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. +**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 cover the supported OpenClaw task-definition 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 lists through the production `enjs` path. Hand-constructed tests will remain only for focused primitive conversion boundaries. - -**Alternative considered:** Storing opaque OpenClaw JSON would reduce conversion work but would weaken validation, obscure compatibility changes, and couple the backend to unrelated runtime fields. - -### 5. Use separate action and task-list marks - -The automation module will use an independently versioned `%project` action mark for complete projection commits and a dedicated task-list mark for the JSON scry. The scry mark will directly accept the ordered task list and grow it to `{ "tasks": [...] }`; automation has no subscription surface or heterogeneous scry results that would justify a tagged `$update` union. Keeping the inbound action and outbound task-list 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 the submission through the existing local Gall source boundary and rejects foreign sources. The scry uses the same local boundary. - -**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-list mark. +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 the submission through the existing local Gall source +boundary and rejects foreign sources. The scry uses the same local +boundary. + +**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. +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. +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. +**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. +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. +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. +**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. ## 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. +- **[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. Deploy the new Steward state version, migration, automation types and marks, storage, and scry before or together with the harness projection. -2. Enable the harness projection; its first successful startup reconciliation populates the initially empty automation slice. -3. To roll back the harness integration, disable projection and leave the last Steward snapshot intact; OpenClaw remains authoritative. -4. Do not install the old Steward binary over the new state shape. A backend rollback must retain compatibility with the new state and preserve all pre-existing slices. +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 index edb001a443..293c452931 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md @@ -1,24 +1,49 @@ ## 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. +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 a local, independently versioned `%project` automation action that 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. -- 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 local `%steward` scry that returns the complete stored task projection through JSON conversion. -- Exclude execution tracking, run history, owner-ship replication, owner-client integration, cron manipulation, and non-OpenClaw harnesses from this change. +- 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 a local, independently versioned `%project` automation action + that 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. +- 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 local `%steward` scry 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. +- `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 @@ -26,7 +51,15 @@ None. ## Impact -- Backend: a versioned `%steward` state migration, automation dispatch, new automation action and task-list marks, JSON conversion, local 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. +- Backend: a versioned `%steward` state migration, automation + dispatch, new automation action and task-map marks, JSON + conversion, local 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 index 24d6cc0bd8..8a5257824b 100644 --- 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 @@ -1,22 +1,32 @@ ## 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. +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. +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` +- **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 +- **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 @@ -25,140 +35,213 @@ After `gateway_start`, the OpenClaw harness SHALL read the complete current task ### 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. +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 +- **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 +- **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. +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 +- **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 +- **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 +- **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. +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 +- **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 +- **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 +- **THEN** the integration stops starting retries and new + reconciliations while preserving the last successfully stored + `%steward` projection ### 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. +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 +- **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 +- **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 atomically commits the current task projection +### 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. +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 +- **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 +- **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 +- **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 +- **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 -Each stored task SHALL preserve its ID and the definition fields supplied by OpenClaw for agent ownership, display metadata, enabled state, schedule, session target, wake mode, payload, and creation/update timestamps when those fields are present. The projection SHALL support `cron`, `at`, and `every` schedule variants and SHALL not store the cron job `state` object. +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 task and JSON representation preserve those fields and their absence/presence semantics +- **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 +- **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. +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 +- **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 +- **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 +- **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: Local JSON task scry -`%steward` SHALL expose a local-only scry at `/x/v1/automation/tasks` that returns the complete currently stored task projection as JSON. The response SHALL contain a `tasks` array, and each task SHALL use the supported OpenClaw field names and JSON value shapes while omitting cron job state. +`%steward` SHALL expose a local-only scry at `/x/v1/automation/tasks` +that returns the complete currently stored task projection as JSON. +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 local client scries `/x/v1/automation/tasks` after a snapshot has been accepted -- **THEN** it receives a JSON object containing every currently stored task exactly once in the `tasks` array +- **WHEN** a local 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 local client scries `/x/v1/automation/tasks` while the projection is empty -- **THEN** it receives `{ "tasks": [] }` +- **WHEN** a local client scries `/x/v1/automation/tasks` while the + projection is empty +- **THEN** it receives `{ "tasks": {} }` #### Scenario: Foreign client attempts to read tasks diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 7d713eb8c4..cc2b34f52e 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -1,41 +1,102 @@ ## 1. Automation Types and Conversions -- [x] 1.1 Finalize the v1 automation types for supported task definitions, automation state, complete `%project` actions, and task-list 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. +- [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 -- [ ] 2.1 Introduce a new Steward state version and migrate released state while preserving populated core, trusted-bot, lens, and gateway values and initializing automation empty. -- [ ] 2.2 Add migration tests for populated released state, fresh initialization, persistence, and visible failure instead of silent reset. -- [ ] 2.3 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. -- [ ] 2.4 Enforce the local Gall source boundary for `%project` and reject foreign sources without changing state. -- [ ] 2.5 Add the versioned automation action mark and dedicated task-list scry mark with validated `%project` JSON/noun conversion, `{ "tasks": [...] }` JSON serialization, and no cron job state. -- [ ] 2.6 Add the local `/x/v1/automation/tasks` scry with deterministic task ordering and `{ "tasks": [] }` for empty state. -- [ ] 2.7 After the production JSON marks exist, replace the hand-constructed type and schedule tests from 1.3 with tests that parse realistic normalized `%project` JSON fixtures derived from captured OpenClaw traces. Retain the focused conversion boundary tests, and cover populated, empty, repeated, invalid, and foreign submissions, persistence, task-list serialization, and scry behavior. +- [ ] 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. +- [ ] 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. +- [ ] 2.3 Enforce the local Gall source boundary for `%project` and + reject foreign sources without changing state. +- [ ] 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. +- [ ] 2.5 Add the local `/x/v1/automation/tasks` scry that returns + the stored task map and `{ "tasks": {} }` for empty state. +- [ ] 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. +- [ ] 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. +- [ ] 2.8 Add migration tests for populated released state, fresh + initialization, persistence, and visible failure instead of + silent reset. ## 3. OpenClaw Harness Projection -- [ ] 3.1 Add task normalization that preserves supported definition fields, omits execution state, and produces complete Steward `%project` payloads. -- [ ] 3.2 Add a local Steward adapter that submits `%project` through the monitor-published ship connection and requires successful poke acknowledgement. -- [ ] 3.3 Trigger complete reads with disabled tasks included after `gateway_start` and every `cron_changed` event using the pinned OpenClaw cron access. -- [ ] 3.4 Serialize reconciliation, coalesce triggers received while busy into one follow-up, and prevent snapshots from overtaking one another. -- [ ] 3.5 Retry unavailable cron reads and failed Steward submissions while the gateway remains active, preserving the last successful projection. -- [ ] 3.6 Stop new reconciliation and retry activity on `gateway_stop` without clearing the durable Steward snapshot. -- [ ] 3.7 Replace the temporary diagnostic handler with projection registration while keeping cron telemetry failures isolated. +- [ ] 3.1 Add task normalization that preserves supported definition + fields, omits execution state, and produces complete Steward + `%project` payloads. +- [ ] 3.2 Add a local Steward adapter that submits `%project` + through the monitor-published ship connection and requires + successful poke acknowledgement. +- [ ] 3.3 Trigger complete reads with disabled tasks included after + `gateway_start` and every `cron_changed` event using the + pinned OpenClaw cron access. +- [ ] 3.4 Serialize reconciliation, coalesce triggers received while + busy into one follow-up, and prevent snapshots from overtaking + one another. +- [ ] 3.5 Retry unavailable cron reads and failed Steward + submissions while the gateway remains active, preserving the + last successful projection. +- [ ] 3.6 Stop new reconciliation and retry activity on + `gateway_stop` without clearing the durable Steward snapshot. +- [ ] 3.7 Replace the temporary diagnostic handler with projection + registration while keeping cron telemetry failures isolated. ## 4. Projection Verification -- [ ] 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. -- [ ] 4.2 Test startup reconciliation when cron access is ready, temporarily unavailable, empty, and restored after a stale period. -- [ ] 4.3 Test complete rereads after definition-related and execution-related `cron_changed` events. -- [ ] 4.4 Test serialized delivery, coalesced triggers, trigger arrival during submission, and the worker-exit race. -- [ ] 4.5 Test read failures, submission failures, retry behavior, acknowledgement failures, and gateway shutdown. -- [ ] 4.6 Add ship-level verification that additions, updates, removals, disabled tasks, and restart reconciliation appear in the automation JSON scry. +- [ ] 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. +- [ ] 4.2 Test startup reconciliation when cron access is ready, + temporarily unavailable, empty, and restored after a stale + period. +- [ ] 4.3 Test complete rereads after definition-related and + execution-related `cron_changed` events. +- [ ] 4.4 Test serialized delivery, coalesced triggers, trigger + arrival during submission, and the worker-exit race. +- [ ] 4.5 Test read failures, submission failures, retry behavior, + acknowledgement failures, and gateway shutdown. +- [ ] 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 -- [ ] 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. -- [ ] 5.2 Run the targeted Hoon tests, applicable backend suite, and desk compilation on the development ship. -- [ ] 5.3 Run OpenClaw formatting, linting, type checking, unit tests, and relevant integration tests against the existing pinned runtime. -- [ ] 5.4 Run strict OpenSpec validation and verify implementation coverage for every capability scenario. +- [ ] 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. +- [ ] 5.2 Run the targeted Hoon tests, applicable backend suite, and + desk compilation on the development ship. +- [ ] 5.3 Run OpenClaw formatting, linting, type checking, unit + tests, and relevant integration tests against the existing + pinned runtime. +- [ ] 5.4 Run strict OpenSpec validation and verify implementation + coverage for every capability scenario. From a6c0901213b7e503b21946050faf49c618855815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 13:12:04 +0800 Subject: [PATCH 18/62] steward: key automation tasks without duplicate ids --- desk/sur/steward/automation.hoon | 25 ++++++++++------- desk/tests/lib/steward-automation.hoon | 38 +++++++++++++++++--------- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/desk/sur/steward/automation.hoon b/desk/sur/steward/automation.hoon index 6c1c33a1e8..2ca4321845 100644 --- a/desk/sur/steward/automation.hoon +++ b/desk/sur/steward/automation.hoon @@ -16,13 +16,12 @@ $: kind=(unit @t) text=(unit @t) == -:: $cron-job: the supported definition-only subset of -:: PluginHookGatewayCronJob. Runtime job state and execution history are not -:: part of the automation protocol. +:: $task: the supported definition-only subset of +:: PluginHookGatewayCronJob. The OpenClaw ID is stored separately as the map +:: key. Runtime job state and execution history are not represented. :: -+$ cron-job - $: id=@t - agent-id=(unit @t) ++$ task + $: agent-id=(unit @t) name=(unit @t) description=(unit @t) enabled=(unit ?) @@ -33,20 +32,26 @@ 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 cron-job) + $: tasks=(map @t task) == :: $action: inbound automation actions from the local harness. :: :: %project: atomically replace the complete task projection. :: +$ action - $% [%project tasks=(list cron-job)] + $% [%project tasks=(list identified-task)] == -:: $task-list: the ordered task list returned by the automation scry. +:: $task-map: the ID-keyed task map returned by the automation scry. :: -+$ task-list (list cron-job) ++$ task-map (map @t task) ++ v1 . -- diff --git a/desk/tests/lib/steward-automation.hoon b/desk/tests/lib/steward-automation.hoon index ce5a9f591e..46c995124d 100644 --- a/desk/tests/lib/steward-automation.hoon +++ b/desk/tests/lib/steward-automation.hoon @@ -3,10 +3,9 @@ /- a=steward-automation /+ *test, au=steward-automation |% -++ populated-job - ^- cron-job:v1:a - :* 'task-1' - (some 'agent-1') +++ populated-task + ^- task:v1:a + :* (some 'agent-1') (some 'Daily summary') (some 'Send the daily summary') (some %.y) @@ -69,9 +68,8 @@ == :: ++ test-v1-types-and-optional-fields - =/ empty=cron-job:v1:a - :* '' - ~ + =/ empty=task:v1:a + :* ~ ~ ~ ~ @@ -82,13 +80,27 @@ ~ ~ == - =/ job=cron-job:v1:a populated-job - =/ action=action:v1:a [%project ~[job]] - =/ task-list=task-list:v1:a ~[job] + =/ task=task:v1:a populated-task + =/ identified=identified-task:v1:a ['task-1' task] + =/ tasks=(map @t task:v1:a) + (~(put by *(map @t task:v1:a)) 'task-1' task) + =/ state=state:v1:a tasks + =/ action=action:v1:a [%project ~[identified]] + =/ task-map=task-map:v1:a tasks ;: weld - (expect-eq !>(empty) !>(*cron-job:v1:a)) - (expect-eq !>(`action:v1:a`[%project ~[job]]) !>(action)) - (expect-eq !>(`task-list:v1:a`~[job]) !>(task-list)) + (expect-eq !>(empty) !>(*task:v1:a)) + %+ expect-eq + !>(`identified-task:v1:a`['task-1' task]) + !>(identified) + %+ expect-eq + !>(`state:v1:a`tasks) + !>(state) + %+ expect-eq + !>(`action:v1:a`[%project ~[identified]]) + !>(action) + %+ expect-eq + !>(`task-map:v1:a`tasks) + !>(task-map) == :: ++ test-schedule-variants-and-optional-fields From 2853652e98c32fa1590e1e9370aa8a9715ce057f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 13:43:19 +0800 Subject: [PATCH 19/62] steward: add fresh automation state --- desk/app/steward.hoon | 44 +++++++++++++------ .../tasks.md | 2 +- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index ae67c2ec7c..7703cadefe 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 until +:: its migration is implemented. Fresh installs 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,22 @@ [~[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) + :: Deliberate task-2.1 stub. Do not invent migration behavior while the + :: new state and API are still changing; task 2.7 replaces this crash with + :: the released-state migration after those shapes are validated. :: - `this(state !<(state-0 ole)) + ++ state-0-to-1 + |= old=state-0 + ^- state-1 + ~| 'steward: state-0 migration intentionally not implemented' + !! + -- ++ on-poke |= [=mark =vase] ^- (quip card _this) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index cc2b34f52e..d1e4e76973 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -12,7 +12,7 @@ ## 2. Steward State, Storage, and JSON API -- [ ] 2.1 Introduce the new current Steward state version with an +- [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 From 73d6f5596c6c59cdc60cf990549362e2cfa22d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 13:44:54 +0800 Subject: [PATCH 20/62] gen-moon: add -d option to specify boot directory --- backend/gen-moon.sh | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/backend/gen-moon.sh b/backend/gen-moon.sh index ad4dc7eec6..d3460efceb 100755 --- a/backend/gen-moon.sh +++ b/backend/gen-moon.sh @@ -3,6 +3,9 @@ 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() { @@ -38,25 +41,31 @@ find_download() { usage() { cat < +Usage: $0 [-bt] [-d directory] 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 ":bt" opt +while getopts ":bd:t" opt do case "$opt" in b) boot=true ;; + d) + boot=true + boot_dir=$OPTARG + ;; t) hosted=true ;; @@ -81,6 +90,11 @@ 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" @@ -276,9 +290,9 @@ if [[ ! -x $vere_bin ]]; then chmod +x $vere_bin; fi boot_moon() { - if (( $# != 1 )) + if (( $# != 2 )) then - fatal "boot_moon(): expected the gen-moon JSON response" + fatal "boot_moon(): expected the gen-moon JSON response and boot directory" fi if ! command -v jq > /dev/null @@ -291,6 +305,8 @@ boot_moon() { 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-]+$"))') @@ -305,9 +321,11 @@ boot_moon() { fi moon_name=${moon_id#\~} - if [[ -e $moon_name ]] + mkdir -p -- "$boot_root" + moon_path="$boot_root/$moon_name" + if [[ -e $moon_path ]] then - fatal "Cannot boot $moon_id: $moon_name already exists" + fatal "Cannot boot $moon_id: $moon_path already exists" fi umask 077 @@ -315,8 +333,8 @@ boot_moon() { trap 'rm -f "$key_file"' EXIT printf '%s\n' "$moon_key" > "$key_file" - echo "Booting $moon_id in $moon_name/" >&2 - if ! $vere -w "$moon_name" -k "$key_file" -c "$moon_name" + 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 @@ -330,5 +348,5 @@ printf '%s\n' "$result" if $boot then - boot_moon "$result" + boot_moon "$result" "$boot_dir" fi From a512941f930c320bbc025552691d0a349b8625ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 13:57:44 +0800 Subject: [PATCH 21/62] steward: replace automation projection atomically --- desk/app/steward.hoon | 30 +++- desk/tests/app/steward.hoon | 128 +++++++++++++++--- .../tasks.md | 2 +- 3 files changed, 139 insertions(+), 21 deletions(-) diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index 7703cadefe..5cb8b6709f 100644 --- a/desk/app/steward.hoon +++ b/desk/app/steward.hoon @@ -143,6 +143,11 @@ :: %steward-gateway-action-1 (ga-poke-action:ga-core !<(action:v1:sg vase)) + :: + :: automation snapshots. Local-source authorization is added separately. + :: + %steward-automation-action-1 + (au-poke-action:au-core !<(action:v1:sa vase)) == :: ++ watch @@ -643,9 +648,32 @@ (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 +:: |au-core: automation projection module :: ++ au-core |% + ++ au-poke-action + |= =action:v1:sa + ^+ cor + ?- -.action + %project + =/ projected (au-build-task-map tasks.action) + cor(tasks.automation.state projected) + == + :: Build the complete replacement before mutating state. 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/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 7a3da118fc..0af23108ca 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -1,26 +1,47 @@ -:: 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 +/- l=steward-lens, g=steward-gateway, au=steward-automation /+ *test-agent /= agent /app/steward |% ++ dap %steward -:: agent state — single version (greenfield, no migration). `bots` is the -:: owner-side trusted set. +:: Current agent state. The released state-0 migration remains deliberately +:: stubbed while the state-1 automation API is exercised. :: -+$ state-0 - $: %0 ++$ state-1 + $: %1 owner=(unit ship) bots=(set ship) lens=state:v1:l gateway=state:v1:g + automation=state:v1:au == :: lens run payloads are opaque $json; a simple value suffices for tests :: ++ 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 ~) :: :: 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 @@ -84,6 +105,75 @@ [/activity [~dev %activity] [%fact %activity-update-5 !>(`update:v9:av`update)]] :: :: ========================================================== +:: 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)) +:: +:: ========================================================== :: LENS MODULE TESTS :: ========================================================== :: @@ -96,7 +186,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 +449,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 @@ -499,7 +589,7 @@ :~ (ex-task /activity [~dev %activity] %watch /v5) == ;< res=cage bind:m (got-peek /x/dbug/state) - =/ st !<(state-0 !<(vase q.res)) + =/ st !<(state-1 !<(vase q.res)) (ex-equal !>(max-runs-per-bot.lens.st) !>(`@ud`3.000)) :: ++ test-watch-rejects-foreign-ship @@ -549,7 +639,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 +665,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 +683,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 +699,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 +714,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 +733,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 +749,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 +845,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/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index d1e4e76973..7a3015e6e2 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -17,7 +17,7 @@ released-state migration to fail visibly, and use a nuked disposable development agent while validating the new state shape. -- [ ] 2.2 Implement atomic `%project` commits keyed by task ID, +- [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. From 717b8333514c6605e48a67b2411463e97837145e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 14:05:20 +0800 Subject: [PATCH 22/62] steward: restrict automation projection locally --- desk/app/steward.hoon | 3 ++- desk/tests/app/steward.hoon | 20 +++++++++++++++++++ .../tasks.md | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index 5cb8b6709f..5a1c8278bc 100644 --- a/desk/app/steward.hoon +++ b/desk/app/steward.hoon @@ -144,7 +144,7 @@ %steward-gateway-action-1 (ga-poke-action:ga-core !<(action:v1:sg vase)) :: - :: automation snapshots. Local-source authorization is added separately. + :: automation snapshots. Authorization is enforced in au-poke-action. :: %steward-automation-action-1 (au-poke-action:au-core !<(action:v1:sa vase)) @@ -655,6 +655,7 @@ ++ au-poke-action |= =action:v1:sa ^+ cor + ?> =(src.bowl our.bowl) ?- -.action %project =/ projected (au-build-task-map tasks.action) diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 0af23108ca..361b671505 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -173,6 +173,26 @@ (~(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') + =/ task-b=task:v1:au (automation-task 'Task B') + =/ initial=(list identified-task:v1:au) ~[['task-a' task-a]] + =/ foreign=(list identified-task:v1:au) ~[['task-b' task-b]] + ;< ~ bind:m setup + ;< ~ bind:m (project-automation initial) + ;< ~ bind:m + %- ex-fail + %- (do-as ~zod) + (project-automation foreign) + ;< 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)) +:: :: ========================================================== :: LENS MODULE TESTS :: ========================================================== diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 7a3015e6e2..937e9a17b8 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -21,7 +21,7 @@ including empty and repeated projections, removal by omission, duplicate-ID rejection, and unchanged state after invalid input. -- [ ] 2.3 Enforce the local Gall source boundary for `%project` and +- [x] 2.3 Enforce the local Gall source boundary for `%project` and reject foreign sources without changing state. - [ ] 2.4 Add the versioned automation action mark and dedicated task-map scry mark with validated `%project` JSON/noun From fed3d223fb5fd67e7ed45ba4d008a75482a8d086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 14:47:14 +0800 Subject: [PATCH 23/62] steward: add automation JSON marks --- desk/lib/steward/automation-json.hoon | 189 ++++++++++++++++++ desk/mar/steward/automation/action-1.hoon | 17 ++ desk/mar/steward/automation/task-map-1.hoon | 17 ++ desk/tests/lib/steward-automation-json.hoon | 134 +++++++++++++ .../tasks.md | 2 +- 5 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 desk/lib/steward/automation-json.hoon create mode 100644 desk/mar/steward/automation/action-1.hoon create mode 100644 desk/mar/steward/automation/task-map-1.hoon create mode 100644 desk/tests/lib/steward-automation-json.hoon diff --git a/desk/lib/steward/automation-json.hoon b/desk/lib/steward/automation-json.hoon new file mode 100644 index 0000000000..3806f2fcc4 --- /dev/null +++ b/desk/lib/steward/automation-json.hoon @@ -0,0 +1,189 @@ +:: 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 +|% +++ duration-from-json + =, dejs:format + (cu milliseconds-to-duration:au ni) +++ date-from-json + =, dejs:format + (cu unix-milliseconds-to-date:au ni) +++ optional-from-json + |* [key=@t wit=$-(json *) jon=json] + ?> ?=([%o *] jon) + =/ value (~(get by p.jon) key) + ?~ value ~ + (some (wit u.value)) +++ schedule-from-json + :: OpenClaw schedules use a `kind` field, not a tagged JSON object. + |= jon=json + ^- cron-schedule:v1:a + ?> ?=([%o *] jon) + =, dejs:format + =/ kind (so (~(got by p.jon) 'kind')) + ?: =('cron' kind) + :* %cron + (optional-from-json 'expr' so jon) + (optional-from-json 'tz' so jon) + (optional-from-json 'staggerMs' duration-from-json jon) + == + ?: =('at' kind) + [%at (optional-from-json 'at' date-from-json jon)] + ?: =('every' kind) + :* %every + (optional-from-json 'everyMs' duration-from-json jon) + (optional-from-json 'anchorMs' date-from-json jon) + == + ~|(bad-schedule-kind+kind !!) +++ payload-from-json + |= jon=json + ^- cron-payload:v1:a + =, dejs:format + :* (optional-from-json 'kind' so jon) + (optional-from-json 'text' so jon) + == +++ task-from-json + |= jon=json + ^- task:v1:a + =, dejs:format + :* (optional-from-json 'agentId' so jon) + (optional-from-json 'name' so jon) + (optional-from-json 'description' so jon) + (optional-from-json 'enabled' bo jon) + (optional-from-json 'schedule' schedule-from-json jon) + (optional-from-json 'sessionTarget' so jon) + (optional-from-json 'wakeMode' so jon) + (optional-from-json 'payload' payload-from-json jon) + (optional-from-json 'createdAtMs' date-from-json jon) + (optional-from-json 'updatedAtMs' date-from-json jon) + == +++ identified-task-from-json + |= jon=json + ^- identified-task:v1:a + ?> ?=([%o *] jon) + =/ id-json=json (~(got by p.jon) 'id') + =, dejs:format + [(so id-json) (task-from-json jon)] +++ project-from-json + |= jon=json + ^- (list identified-task:v1:a) + =, dejs:format + =/ tasks=(list identified-task:v1:a) + ((ot tasks+(ar identified-task-from-json) ~) 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-from-json + |= jon=json + ^- action:v1:a + =, dejs:format + %. jon + (of ~[[%project project-from-json]]) +++ schedule-to-json + |= schedule=cron-schedule:v1:a + ^- json + =, enjs:format + ?- -.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-to-json + |= payload=cron-payload:v1:a + ^- json + =, enjs:format + =/ fields=(list [@t json]) ~ + =. fields ?~(kind.payload fields [['kind' s+u.kind.payload] fields]) + =. fields ?~(text.payload fields [['text' s+u.text.payload] fields]) + (pairs fields) +++ task-to-json + |= =task:v1:a + ^- json + =, enjs:format + =/ 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-to-json 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-to-json 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-to-json + |= entry=identified-task:v1:a + ^- json + =/ jon=json (task-to-json task.entry) + ?> ?=([%o *] jon) + [%o (~(put by p.jon) 'id' [%s id.entry])] +++ action-to-json + |= =action:v1:a + ^- json + =, enjs:format + ?- -.action + %project + (frond 'project' (frond 'tasks' a+(turn tasks.action identified-task-to-json))) + == +++ task-map-from-json + |= jon=json + ^- task-map:v1:a + =, dejs:format + ((ot tasks+(om task-from-json) ~) jon) +++ task-map-to-json + |= tasks=task-map:v1:a + ^- json + =, enjs:format + (frond 'tasks' [%o (~(run by tasks) task-to-json)]) +-- diff --git a/desk/mar/steward/automation/action-1.hoon b/desk/mar/steward/automation/action-1.hoon new file mode 100644 index 0000000000..ea357c1bf4 --- /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-to-json:aj action) + -- +++ grab + |% + ++ noun action:v1:a + ++ json action-from-json: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..e046e95d6c --- /dev/null +++ b/desk/mar/steward/automation/task-map-1.hoon @@ -0,0 +1,17 @@ +:: %steward-automation-task-map-1: 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-to-json:aj tasks) + -- +++ grab + |% + ++ noun task-map:v1:a + ++ json task-map-from-json:aj + -- +-- diff --git a/desk/tests/lib/steward-automation-json.hoon b/desk/tests/lib/steward-automation-json.hoon new file mode 100644 index 0000000000..12bdfd6f7d --- /dev/null +++ b/desk/tests/lib/steward-automation-json.hoon @@ -0,0 +1,134 @@ +:: steward automation production JSON codec tests +:: +/- a=steward-automation +/+ *test, aj=steward-automation-json +|% +++ parse-json + |= body=@t + ^- json + (need (de:json:html body)) +++ parse-action + |= body=@t + ^- action:v1:a + (action-from-json:aj (parse-json body)) +++ empty-task + ^- task:v1:a + :* ~ + ~ + ~ + ~ + ~ + ~ + ~ + ~ + ~ + ~ + == +++ cron-task + ^- task:v1:a + :* (some 'agent-1') + (some 'Daily summary') + (some 'Send the daily summary') + (some %.y) + (some [%cron (some '0 9 * * *') (some 'UTC') (some ~s30)]) + (some 'isolated') + (some 'now') + (some [(some 'agentTurn') (some 'Summarize activity')]) + (some ~2024.1.1) + (some ~2024.1.2) + == +++ at-task + ^- task:v1:a + :* ~ + (some 'One shot') + ~ + (some %.n) + (some [%at (some ~2024.1.1)]) + ~ + ~ + ~ + ~ + ~ + == +++ every-task + ^- task:v1:a + :* ~ + (some 'Quarter hourly') + ~ + ~ + (some [%every (some ~m15) (some ~2024.1.1)]) + ~ + ~ + ~ + ~ + ~ + == +++ named-task + ^- task:v1:a + :* ~ + (some 'Named task') + ~ + ~ + ~ + ~ + ~ + ~ + ~ + ~ + == +:: +++ test-populated-action-parses-and-roundtrips + =/ input=@t + '{"project":{"tasks":[{"id":"cron-1","agentId":"agent-1","name":"Daily summary","description":"Send the daily summary","enabled":true,"schedule":{"kind":"cron","expr":"0 9 * * *","tz":"UTC","staggerMs":30000},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","text":"Summarize activity"},"createdAtMs":1704067200000,"updatedAtMs":1704153600000,"state":{"lastStatus":"ok"},"lastRunAtMs":1704153600000},{"id":"at-1","name":"One shot","enabled":false,"schedule":{"kind":"at","at":1704067200000}},{"id":"every-1","name":"Quarter hourly","schedule":{"kind":"every","everyMs":900000,"anchorMs":1704067200000}}]}}' + =/ normalized=@t + '{"project":{"tasks":[{"id":"cron-1","agentId":"agent-1","name":"Daily summary","description":"Send the daily summary","enabled":true,"schedule":{"kind":"cron","expr":"0 9 * * *","tz":"UTC","staggerMs":30000},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","text":"Summarize activity"},"createdAtMs":1704067200000,"updatedAtMs":1704153600000},{"id":"at-1","name":"One shot","enabled":false,"schedule":{"kind":"at","at":1704067200000}},{"id":"every-1","name":"Quarter hourly","schedule":{"kind":"every","everyMs":900000,"anchorMs":1704067200000}}]}}' + =/ expected=action:v1:a + [%project ~[['cron-1' cron-task] ['at-1' at-task] ['every-1' every-task]]] + =/ actual=action:v1:a (parse-action input) + ;: weld + (expect-eq !>(expected) !>(actual)) + %+ expect-eq + !>((parse-json normalized)) + !>((action-to-json:aj actual)) + == +:: +++ test-absent-optionals-roundtrip + =/ expected=action:v1:a [%project ~[['empty' empty-task]]] + =/ actual=action:v1:a + (parse-action '{"project":{"tasks":[{"id":"empty"}]}}') + ;: weld + (expect-eq !>(expected) !>(actual)) + %+ expect-eq + !>((parse-json '{"project":{"tasks":[{"id":"empty"}]}}')) + !>((action-to-json:aj actual)) + == +:: +++ 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-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-to-json:aj tasks) + ;: weld + (expect-eq !>(expected) !>(actual)) + (expect-eq !>(tasks) !>((task-map-from-json: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-to-json:aj tasks))) + (expect-eq !>(tasks) !>((task-map-from-json:aj expected))) + == +-- diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 937e9a17b8..8ffd2cf74b 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -23,7 +23,7 @@ input. - [x] 2.3 Enforce the local Gall source boundary for `%project` and reject foreign sources without changing state. -- [ ] 2.4 Add the versioned automation action mark and dedicated +- [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 From 5fa5b7ef8261f9ec405b0fb1266fc95c1f0e347b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 15:03:40 +0800 Subject: [PATCH 24/62] steward: expose automation task scry --- desk/app/steward.hoon | 12 +++++- desk/tests/app/steward.hoon | 42 +++++++++++++++++++ .../tasks.md | 2 +- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index 5a1c8278bc..4dfb044147 100644 --- a/desk/app/steward.hoon +++ b/desk/app/steward.hoon @@ -162,8 +162,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 @@ -661,6 +662,13 @@ =/ 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 duplicate ID :: crashes here, leaving the previous projection untouched. :: diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 361b671505..86df8eb91d 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -193,6 +193,48 @@ (~(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 + %- eval-mare + =/ m (mare ,~) + ^- form:m + =/ task-a=task:v1:au (automation-task 'Task A') + =/ task-b=task:v1:au (automation-task 'Task B') + =/ projected=(list identified-task:v1:au) + ~[['task-a' task-a] ['task-b' task-b]] + ;< ~ bind:m setup + ;< ~ bind:m (project-automation projected) + ;< 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) + (ex-equal !>(actual) !>(expected)) +:: +++ test-automation-tasks-scry-rejects-foreign + %- eval-mare + =/ m (mare ,~) + ^- form:m + ;< ~ bind:m setup + ;< ~ bind:m (set-src ~zod) + |= s=state + =/ result + (mule |.((~(on-peek agent.s bowl.s) /x/v1/automation/tasks))) + ?: ?=(%& -.result) + |+~['expected foreign /x/v1/automation/tasks peek to crash'] + &+[~ s] +:: :: ========================================================== :: LENS MODULE TESTS :: ========================================================== diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 8ffd2cf74b..b07e11d7c6 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -28,7 +28,7 @@ conversion, `{ "tasks": { "": , ... } }` JSON serialization, no duplicated IDs in task values, and no cron job state. -- [ ] 2.5 Add the local `/x/v1/automation/tasks` scry that returns +- [x] 2.5 Add the local `/x/v1/automation/tasks` scry that returns the stored task map and `{ "tasks": {} }` for empty state. - [ ] 2.6 Test the fresh-state implementation through the production marks and scry: populated, empty, repeated, invalid, and From 91aab6698fbc090a91f1f982cee247a874b8c595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 15:31:09 +0800 Subject: [PATCH 25/62] steward: test automation projection end to end --- desk/tests/app/steward.hoon | 52 ++++++-- desk/tests/lib/steward-automation-json.hoon | 117 ++++++++++++------ desk/tests/lib/steward-automation.hoon | 77 +----------- .../tasks.md | 2 +- packages/openclaw/src/fixtures/README.md | 13 ++ ...penclaw-2026.5.28-cron-jobs.sanitized.json | 67 ++++++++++ 6 files changed, 200 insertions(+), 128 deletions(-) create mode 100644 packages/openclaw/src/fixtures/README.md create mode 100644 packages/openclaw/src/fixtures/openclaw-2026.5.28-cron-jobs.sanitized.json diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 86df8eb91d..984181ee97 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -2,7 +2,7 @@ :: /- s=steward, a=activity, av=activity-ver /- l=steward-lens, g=steward-gateway, au=steward-automation -/+ *test-agent +/+ *test-agent, aj=steward-automation-json /= agent /app/steward |% ++ dap %steward @@ -42,6 +42,21 @@ ;< * 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-from-json: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","text":"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","text":"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","text":"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","text":"Send a playful reminder."},"createdAtMs":1785735243782,"updatedAtMs":1785740230441}}}' :: :: 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 @@ -178,15 +193,13 @@ =/ 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]] - =/ foreign=(list identified-task:v1:au) ~[['task-b' task-b]] ;< ~ bind:m setup ;< ~ bind:m (project-automation initial) ;< ~ bind:m %- ex-fail %- (do-as ~zod) - (project-automation foreign) + (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) @@ -204,22 +217,41 @@ =/ actual=task-map:v1:au !<(task-map:v1:au q.res) (ex-equal !>(actual) !>(*(map @t task:v1:au))) :: -++ test-automation-tasks-scry-populated +++ test-automation-tasks-scry-populated-json %- eval-mare =/ m (mare ,~) ^- form:m - =/ task-a=task:v1:au (automation-task 'Task A') - =/ task-b=task:v1:au (automation-task 'Task B') - =/ projected=(list identified-task:v1:au) - ~[['task-a' task-a] ['task-b' task-b]] + =/ action=action:v1:au + (action-from-json:aj (parse-json trace-project-json)) + =/ projected=(list identified-task:v1:au) tasks.action ;< ~ bind:m setup - ;< ~ bind:m (project-automation projected) + ;< ~ 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-to-json: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-from-json: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)) :: ++ test-automation-tasks-scry-rejects-foreign diff --git a/desk/tests/lib/steward-automation-json.hoon b/desk/tests/lib/steward-automation-json.hoon index 12bdfd6f7d..1e93a93845 100644 --- a/desk/tests/lib/steward-automation-json.hoon +++ b/desk/tests/lib/steward-automation-json.hoon @@ -1,7 +1,7 @@ :: steward automation production JSON codec tests :: /- a=steward-automation -/+ *test, aj=steward-automation-json +/+ *test, aj=steward-automation-json, au=steward-automation |% ++ parse-json |= body=@t @@ -24,6 +24,32 @@ ~ ~ == +++ 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 'agent-1') @@ -37,32 +63,6 @@ (some ~2024.1.1) (some ~2024.1.2) == -++ at-task - ^- task:v1:a - :* ~ - (some 'One shot') - ~ - (some %.n) - (some [%at (some ~2024.1.1)]) - ~ - ~ - ~ - ~ - ~ - == -++ every-task - ^- task:v1:a - :* ~ - (some 'Quarter hourly') - ~ - ~ - (some [%every (some ~m15) (some ~2024.1.1)]) - ~ - ~ - ~ - ~ - ~ - == ++ named-task ^- task:v1:a :* ~ @@ -76,22 +76,52 @@ ~ ~ == +++ 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","text":"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","text":"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","text":"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","text":"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-populated-action-parses-and-roundtrips - =/ input=@t - '{"project":{"tasks":[{"id":"cron-1","agentId":"agent-1","name":"Daily summary","description":"Send the daily summary","enabled":true,"schedule":{"kind":"cron","expr":"0 9 * * *","tz":"UTC","staggerMs":30000},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","text":"Summarize activity"},"createdAtMs":1704067200000,"updatedAtMs":1704153600000,"state":{"lastStatus":"ok"},"lastRunAtMs":1704153600000},{"id":"at-1","name":"One shot","enabled":false,"schedule":{"kind":"at","at":1704067200000}},{"id":"every-1","name":"Quarter hourly","schedule":{"kind":"every","everyMs":900000,"anchorMs":1704067200000}}]}}' - =/ normalized=@t - '{"project":{"tasks":[{"id":"cron-1","agentId":"agent-1","name":"Daily summary","description":"Send the daily summary","enabled":true,"schedule":{"kind":"cron","expr":"0 9 * * *","tz":"UTC","staggerMs":30000},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","text":"Summarize activity"},"createdAtMs":1704067200000,"updatedAtMs":1704153600000},{"id":"at-1","name":"One shot","enabled":false,"schedule":{"kind":"at","at":1704067200000}},{"id":"every-1","name":"Quarter hourly","schedule":{"kind":"every","everyMs":900000,"anchorMs":1704067200000}}]}}' - =/ expected=action:v1:a - [%project ~[['cron-1' cron-task] ['at-1' at-task] ['every-1' every-task]]] - =/ actual=action:v1:a (parse-action input) +++ test-trace-derived-action-grab-and-grow + =/ actual=action:v1:a (parse-action trace-project-json) ;: weld - (expect-eq !>(expected) !>(actual)) + (expect-eq !>(trace-action) !>(actual)) %+ expect-eq - !>((parse-json normalized)) + !>((parse-json trace-project-json)) !>((action-to-json:aj actual)) == :: +:: No cron-expression job was present in the captured runtime history. Keep +:: this synthetic case focused on the third supported schedule codec. +:: +++ test-focused-cron-schedule-codec + =/ body=@t + '{"project":{"tasks":[{"id":"cron-focused","agentId":"agent-1","name":"Daily summary","description":"Send the daily summary","enabled":true,"schedule":{"kind":"cron","expr":"0 9 * * *","tz":"UTC","staggerMs":30000},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","text":"Summarize activity"},"createdAtMs":1704067200000,"updatedAtMs":1704153600000}]}}' + =/ expected=action:v1:a [%project ~[['cron-focused' cron-task]]] + =/ actual=action:v1:a (parse-action body) + ;: weld + (expect-eq !>(expected) !>(actual)) + (expect-eq !>((parse-json body)) !>((action-to-json: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-to-json:aj actual))) + == ++ test-absent-optionals-roundtrip =/ expected=action:v1:a [%project ~[['empty' empty-task]]] =/ actual=action:v1:a @@ -102,17 +132,23 @@ !>((parse-json '{"project":{"tasks":[{"id":"empty"}]}}')) !>((action-to-json:aj actual)) == -:: +++ test-invalid-json-rejected + %- expect-fail + |. (parse-action '{"project":') ++ 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-to-json:aj trace-task-map) + ;: weld + (expect-eq !>((parse-json trace-task-map-json)) !>(actual)) + (expect-eq !>(trace-task-map) !>((task-map-from-json: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) @@ -123,7 +159,6 @@ (expect-eq !>(expected) !>(actual)) (expect-eq !>(tasks) !>((task-map-from-json: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":{}}') diff --git a/desk/tests/lib/steward-automation.hoon b/desk/tests/lib/steward-automation.hoon index 46c995124d..83c01d1fc4 100644 --- a/desk/tests/lib/steward-automation.hoon +++ b/desk/tests/lib/steward-automation.hoon @@ -1,22 +1,7 @@ -:: steward automation type and time conversion tests +:: steward automation time conversion tests :: -/- a=steward-automation /+ *test, au=steward-automation |% -++ populated-task - ^- task:v1:a - :* (some 'agent-1') - (some 'Daily summary') - (some 'Send the daily summary') - (some %.y) - (some [%cron (some '0 9 * * *') (some 'UTC') (some ~s30)]) - (some 'isolated') - (some 'now') - (some [(some 'agentTurn') (some 'Summarize activity')]) - (some ~2024.1.1) - (some ~2024.1.2) - == -:: ++ test-duration-boundaries-and-roundtrips ;: weld %+ expect-eq @@ -66,64 +51,4 @@ !>(`@ud`1.704.067.200.000) !> (date-to-unix-milliseconds:au (unix-milliseconds-to-date:au 1.704.067.200.000)) == -:: -++ test-v1-types-and-optional-fields - =/ empty=task:v1:a - :* ~ - ~ - ~ - ~ - ~ - ~ - ~ - ~ - ~ - ~ - == - =/ task=task:v1:a populated-task - =/ identified=identified-task:v1:a ['task-1' task] - =/ tasks=(map @t task:v1:a) - (~(put by *(map @t task:v1:a)) 'task-1' task) - =/ state=state:v1:a tasks - =/ action=action:v1:a [%project ~[identified]] - =/ task-map=task-map:v1:a tasks - ;: weld - (expect-eq !>(empty) !>(*task:v1:a)) - %+ expect-eq - !>(`identified-task:v1:a`['task-1' task]) - !>(identified) - %+ expect-eq - !>(`state:v1:a`tasks) - !>(state) - %+ expect-eq - !>(`action:v1:a`[%project ~[identified]]) - !>(action) - %+ expect-eq - !>(`task-map:v1:a`tasks) - !>(task-map) - == -:: -++ test-schedule-variants-and-optional-fields - =/ empty-cron=cron-schedule:v1:a [%cron ~ ~ ~] - =/ cron=cron-schedule:v1:a - [%cron (some '0 9 * * *') (some 'UTC') (some ~s30)] - =/ empty-at=cron-schedule:v1:a [%at ~] - =/ at=cron-schedule:v1:a [%at (some ~2024.2.29..12.34.56)] - =/ empty-every=cron-schedule:v1:a [%every ~ ~] - =/ every=cron-schedule:v1:a - [%every (some ~m15) (some ~2024.1.1)] - ;: weld - (expect-eq !>(`cron-schedule:v1:a`[%cron ~ ~ ~]) !>(empty-cron)) - %+ expect-eq - !>(`cron-schedule:v1:a`[%cron (some '0 9 * * *') (some 'UTC') (some ~s30)]) - !>(cron) - (expect-eq !>(`cron-schedule:v1:a`[%at ~]) !>(empty-at)) - %+ expect-eq - !>(`cron-schedule:v1:a`[%at (some ~2024.2.29..12.34.56)]) - !>(at) - (expect-eq !>(`cron-schedule:v1:a`[%every ~ ~]) !>(empty-every)) - %+ expect-eq - !>(`cron-schedule:v1:a`[%every (some ~m15) (some ~2024.1.1)]) - !>(every) - == -- diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index b07e11d7c6..996820736e 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -30,7 +30,7 @@ job state. - [x] 2.5 Add the local `/x/v1/automation/tasks` scry that returns the stored task map and `{ "tasks": {} }` for empty state. -- [ ] 2.6 Test the fresh-state implementation through the production +- [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 diff --git a/packages/openclaw/src/fixtures/README.md b/packages/openclaw/src/fixtures/README.md new file mode 100644 index 0000000000..d38fbced53 --- /dev/null +++ b/packages/openclaw/src/fixtures/README.md @@ -0,0 +1,13 @@ +# 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. They came from session file +`c87e8f5e-1a0c-4866-b967-5c3f44311ca7.jsonl`, jobs +`0634ad7a-3ba1-4a65-b64a-db04658d8e64` (`at`) and +`f8a8741a-af0f-4cf1-8da9-43faf429cc7b` (`every`). No captured +cron-expression job was present. + +Names, IDs, message text, and delivery recipients were replaced. Field +presence, schedule and timestamp values, 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..255bf1dae4 --- /dev/null +++ b/packages/openclaw/src/fixtures/openclaw-2026.5.28-cron-jobs.sanitized.json @@ -0,0 +1,67 @@ +[ + { + "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 + } + } +] From 18250f72dcd4bb074bf73c0b3b34c8c1be3fa1a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 15:39:00 +0800 Subject: [PATCH 26/62] steward: migrate released automation state --- desk/app/steward.hoon | 17 ++++++++++------- .../tasks.md | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index 4dfb044147..65088bda9d 100644 --- a/desk/app/steward.hoon +++ b/desk/app/steward.hoon @@ -15,8 +15,8 @@ /+ default-agent, verb, dbug |% +$ card card:agent:gall -:: Versioned persisted state. state-0 is released and remains decodable until -:: its migration is implemented. Fresh installs use state-1. +:: 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 @@ -68,15 +68,18 @@ =? old ?=(%0 -.old) (state-0-to-1 old) ?> ?=(%1 -.old) `this(state old) - :: Deliberate task-2.1 stub. Do not invent migration behavior while the - :: new state and API are still changing; task 2.7 replaces this crash with - :: the released-state migration after those shapes are validated. + :: Preserve every released field and initialize the new module empty. :: ++ state-0-to-1 |= old=state-0 ^- state-1 - ~| 'steward: state-0 migration intentionally not implemented' - !! + :* %1 + owner.old + bots.old + lens.old + gateway.old + *state:v1:sa + == -- ++ on-poke |= [=mark =vase] diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 996820736e..cab5e39cd4 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -38,7 +38,7 @@ realistic normalized `%project` JSON fixtures derived from captured OpenClaw traces while retaining focused conversion boundary tests. -- [ ] 2.7 After the state shape, storage behavior, marks, and scry +- [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. From f795bba3d212e9874bfc632d08b51c6b67254dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 15:47:56 +0800 Subject: [PATCH 27/62] steward: test released state migration --- desk/tests/app/steward.hoon | 104 +++++++++++++++++- .../tasks.md | 2 +- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 984181ee97..78e12661da 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -6,8 +6,7 @@ /= agent /app/steward |% ++ dap %steward -:: Current agent state. The released state-0 migration remains deliberately -:: stubbed while the state-1 automation API is exercised. +:: Current state and the released state shape accepted by +on-load. :: +$ state-1 $: %1 @@ -17,6 +16,13 @@ gateway=state:v1:g automation=state:v1:au == ++$ state-0 + $: %0 + owner=(unit ship) + bots=(set ship) + lens=state:v1:l + gateway=state:v1:g + == :: lens run payloads are opaque $json; a simple value suffices for tests :: ++ payload ^- json s+'run-record' @@ -119,6 +125,91 @@ =/ =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 :: ========================================================== @@ -669,9 +760,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 @@ -684,7 +775,10 @@ == ;< res=cage bind:m (got-peek /x/dbug/state) =/ st !<(state-1 !<(vase q.res)) - (ex-equal !>(max-runs-per-bot.lens.st) !>(`@ud`3.000)) + ;< ~ 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 diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index cab5e39cd4..35f2b4f4f0 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -42,7 +42,7 @@ have been validated in practice, implement released-state migration preserving populated core, trusted-bot, lens, and gateway values while initializing automation empty. -- [ ] 2.8 Add migration tests for populated released state, fresh +- [x] 2.8 Add migration tests for populated released state, fresh initialization, persistence, and visible failure instead of silent reset. From fc1de23bffcfaf7522b8f02d70011a838299dea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 15:55:52 +0800 Subject: [PATCH 28/62] openclaw: normalize steward automation snapshots --- .../tasks.md | 2 +- .../src/steward-automation-projection.test.ts | 157 +++++++++++ .../src/steward-automation-projection.ts | 262 ++++++++++++++++++ 3 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/steward-automation-projection.test.ts create mode 100644 packages/openclaw/src/steward-automation-projection.ts diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 35f2b4f4f0..4cb2641e4a 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -48,7 +48,7 @@ ## 3. OpenClaw Harness Projection -- [ ] 3.1 Add task normalization that preserves supported definition +- [x] 3.1 Add task normalization that preserves supported definition fields, omits execution state, and produces complete Steward `%project` payloads. - [ ] 3.2 Add a local Steward adapter that submits `%project` 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..648c692b7a --- /dev/null +++ b/packages/openclaw/src/steward-automation-projection.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; + +import capturedCronJobs from './fixtures/openclaw-2026.5.28-cron-jobs.sanitized.json'; +import { normalizeStewardAutomationProject } from './steward-automation-projection.js'; + +type HookCronJob = Parameters< + typeof normalizeStewardAutomationProject +>[0][number]; + +function runtimeJob(value: unknown): HookCronJob { + return value as HookCronJob; +} + +describe('Steward automation projection normalization', () => { + it('normalizes captured jobs and omits execution-only fields', () => { + const result = normalizeStewardAutomationProject( + 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', + text: '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', + text: 'Send a playful reminder.', + }, + createdAtMs: 1_785_735_243_782, + updatedAtMs: 1_785_740_230_441, + }, + ], + }, + }); + expect(JSON.stringify(result)).not.toMatch( + /state|delivery|deleteAfterRun|message/ + ); + }); + + it('preserves false and zero and prefers declared payload text', () => { + const result = normalizeStewardAutomationProject([ + runtimeJob({ + id: 'disabled-zero', + enabled: false, + schedule: { + kind: 'cron', + expr: '', + tz: '', + staggerMs: 0, + }, + payload: { + kind: '', + text: 'declared text', + message: 'runtime message', + unknown: 'drop me', + }, + createdAtMs: 0, + updatedAtMs: 0, + 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: '', text: 'declared text' }, + createdAtMs: 0, + updatedAtMs: 0, + }, + ], + }, + }); + }); + + it('preserves input order and returns a complete empty project', () => { + expect( + normalizeStewardAutomationProject([ + runtimeJob({ id: 'second' }), + runtimeJob({ id: 'first' }), + ]).project.tasks.map(({ id }) => id) + ).toEqual(['second', 'first']); + expect(normalizeStewardAutomationProject([])).toEqual({ + project: { tasks: [] }, + }); + }); + + it.each([ + [ + 'invalid at date', + { id: 'bad-at', schedule: { kind: 'at', at: '2026-02-31T00:00:00Z' } }, + /expected an ISO timestamp/, + ], + [ + 'invalid number', + { id: 'bad-every', schedule: { kind: 'every', everyMs: -1 } }, + /expected a non-negative safe integer/, + ], + [ + 'unsupported schedule', + { id: 'bad-kind', schedule: { kind: 'on-exit' } }, + /unsupported value on-exit/, + ], + [ + 'invalid declared payload text', + { id: 'bad-text', payload: { text: 1, message: 'fallback' } }, + /payload.text: expected a string/, + ], + [ + 'invalid runtime payload message', + { id: 'bad-message', payload: { message: false } }, + /payload.message: expected a string/, + ], + ])('rejects %s', (_name, job, error) => { + expect(() => normalizeStewardAutomationProject([runtimeJob(job)])).toThrow( + error + ); + }); + + it('rejects duplicate IDs before producing a project action', () => { + expect(() => + normalizeStewardAutomationProject([ + 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..3c6dc29f2e --- /dev/null +++ b/packages/openclaw/src/steward-automation-projection.ts @@ -0,0 +1,262 @@ +import type { PluginHookGatewayCronJob } from 'openclaw/plugin-sdk/types'; + +export type StewardAutomationSchedule = + | { + kind: 'cron'; + expr?: string; + tz?: string; + staggerMs?: number; + } + | { + kind: 'at'; + at?: number; + } + | { + kind: 'every'; + everyMs?: number; + anchorMs?: number; + }; + +export interface StewardAutomationPayload { + kind?: string; + text?: string; +} + +export interface StewardAutomationTask { + id: string; + agentId?: string; + name?: string; + description?: string; + enabled?: boolean; + schedule?: StewardAutomationSchedule; + sessionTarget?: string; + wakeMode?: string; + payload?: StewardAutomationPayload; + createdAtMs?: number; + updatedAtMs?: number; +} + +export interface StewardAutomationProjectAction { + project: { + tasks: StewardAutomationTask[]; + }; +} + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireRecord(value: unknown, field: string): UnknownRecord { + if (!isRecord(value)) { + throw new Error(`Invalid ${field}: expected an object`); + } + return value; +} + +function requiredString(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new Error(`Invalid ${field}: expected a string`); + } + return value; +} + +function optionalString(value: unknown, field: string): string | undefined { + if (value === undefined) { + return undefined; + } + return requiredString(value, field); +} + +function optionalBoolean(value: unknown, field: string): boolean | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'boolean') { + throw new Error(`Invalid ${field}: expected a boolean`); + } + return value; +} + +function optionalNaturalNumber( + value: unknown, + field: string +): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`Invalid ${field}: expected a non-negative safe integer`); + } + return value; +} + +function optionalIsoTimestamp( + value: unknown, + field: string +): number | undefined { + if (value === undefined) { + return undefined; + } + const timestamp = requiredString(value, field); + const parts = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(?:Z|[+-](\d{2}):(\d{2}))$/.exec( + timestamp + ); + const milliseconds = Date.parse(timestamp); + if (!parts || !Number.isSafeInteger(milliseconds) || milliseconds < 0) { + throw new Error(`Invalid ${field}: expected an ISO timestamp`); + } + const [, yearText, monthText, dayText, hourText, minuteText, secondText] = + parts; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const validCalendarDate = + month >= 1 && + month <= 12 && + day >= 1 && + day <= new Date(Date.UTC(year, month, 0)).getUTCDate() && + Number(hourText) <= 23 && + Number(minuteText) <= 59 && + Number(secondText) <= 59 && + (parts[7] === undefined || Number(parts[7]) <= 23) && + (parts[8] === undefined || Number(parts[8]) <= 59); + if (!validCalendarDate) { + throw new Error(`Invalid ${field}: expected an ISO timestamp`); + } + return milliseconds; +} + +function normalizeSchedule( + value: unknown, + jobId: string +): StewardAutomationSchedule | undefined { + if (value === undefined) { + return undefined; + } + const schedule = requireRecord(value, `cron job ${jobId} schedule`); + const field = (name: string) => `cron job ${jobId} schedule.${name}`; + + switch (schedule.kind) { + case 'cron': { + const expr = optionalString(schedule.expr, field('expr')); + const tz = optionalString(schedule.tz, field('tz')); + const staggerMs = optionalNaturalNumber( + schedule.staggerMs, + field('staggerMs') + ); + return { + kind: 'cron', + ...(expr === undefined ? {} : { expr }), + ...(tz === undefined ? {} : { tz }), + ...(staggerMs === undefined ? {} : { staggerMs }), + }; + } + case 'at': { + const at = optionalIsoTimestamp(schedule.at, field('at')); + return { + kind: 'at', + ...(at === undefined ? {} : { at }), + }; + } + case 'every': { + const everyMs = optionalNaturalNumber(schedule.everyMs, field('everyMs')); + const anchorMs = optionalNaturalNumber( + schedule.anchorMs, + field('anchorMs') + ); + return { + kind: 'every', + ...(everyMs === undefined ? {} : { everyMs }), + ...(anchorMs === undefined ? {} : { anchorMs }), + }; + } + default: + throw new Error( + `Invalid cron job ${jobId} schedule.kind: unsupported value ${String(schedule.kind)}` + ); + } +} + +function normalizePayload( + value: unknown, + jobId: string +): StewardAutomationPayload | undefined { + if (value === undefined) { + return undefined; + } + const payload = requireRecord(value, `cron job ${jobId} payload`); + const kind = optionalString(payload.kind, `cron job ${jobId} payload.kind`); + // The pinned declaration says `text`; captured 2026.5.28 runtime values use + // `message`. Validate both aliases and expose only Steward's `text`. + const declaredText = optionalString( + payload.text, + `cron job ${jobId} payload.text` + ); + const runtimeMessage = optionalString( + payload.message, + `cron job ${jobId} payload.message` + ); + const text = declaredText ?? runtimeMessage; + return { + ...(kind === undefined ? {} : { kind }), + ...(text === undefined ? {} : { text }), + }; +} + +function normalizeTask(job: PluginHookGatewayCronJob): StewardAutomationTask { + const source = requireRecord(job, 'cron job'); + const id = requiredString(source.id, 'cron job id'); + const field = (name: string) => `cron job ${id} ${name}`; + const agentId = optionalString(source.agentId, field('agentId')); + const name = optionalString(source.name, field('name')); + const description = optionalString(source.description, field('description')); + const enabled = optionalBoolean(source.enabled, field('enabled')); + const schedule = normalizeSchedule(source.schedule, id); + const sessionTarget = optionalString( + source.sessionTarget, + field('sessionTarget') + ); + const wakeMode = optionalString(source.wakeMode, field('wakeMode')); + const payload = normalizePayload(source.payload, id); + const createdAtMs = optionalNaturalNumber( + source.createdAtMs, + field('createdAtMs') + ); + const updatedAtMs = optionalNaturalNumber( + source.updatedAtMs, + field('updatedAtMs') + ); + + return { + 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 }), + }; +} + +/** Normalize one complete OpenClaw cron list into Steward's `%project` JSON. */ +export function normalizeStewardAutomationProject( + jobs: readonly PluginHookGatewayCronJob[] +): StewardAutomationProjectAction { + 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 } }; +} From 872e4d6e77fb01f5ed918b47cfc62b86aa1b112c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:00:38 +0800 Subject: [PATCH 29/62] openclaw: submit steward automation projections --- .../tasks.md | 2 +- .../src/steward-automation-adapter.test.ts | 122 ++++++++++++++++++ .../src/steward-automation-adapter.ts | 38 ++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/steward-automation-adapter.test.ts create mode 100644 packages/openclaw/src/steward-automation-adapter.ts diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 4cb2641e4a..6acffd92b2 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -51,7 +51,7 @@ - [x] 3.1 Add task normalization that preserves supported definition fields, omits execution state, and produces complete Steward `%project` payloads. -- [ ] 3.2 Add a local Steward adapter that submits `%project` +- [x] 3.2 Add a local Steward adapter that submits `%project` through the monitor-published ship connection and requires successful poke acknowledgement. - [ ] 3.3 Trigger complete reads with disabled tasks included after 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..9a319220d2 --- /dev/null +++ b/packages/openclaw/src/steward-automation-adapter.test.ts @@ -0,0 +1,122 @@ +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, + submitStewardAutomationProject, +} from './steward-automation-adapter.js'; +import type { StewardAutomationProjectAction } from './steward-automation-projection.js'; + +const paramsSlot = sharedSlot(API_CLIENT_PARAMS_SLOT); + +const action: StewardAutomationProjectAction = { + project: { + tasks: [ + { + id: 'job-1', + enabled: false, + payload: { kind: 'agentTurn', text: 'check status' }, + }, + ], + }, +}; + +function paramsWithPoke( + poke: SharedApiClientParams['poke'] +): SharedApiClientParams { + return { + poke, + shipName: 'zod', + shipUrl: 'http://localhost:8080', + }; +} + +describe('submitStewardAutomationProject', () => { + 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 submitStewardAutomationProject(action); + + expect(poke).toHaveBeenCalledOnce(); + expect(poke).toHaveBeenCalledWith({ + app: 'steward', + mark: 'steward-automation-action-1', + json: action, + }); + }); + + it('fails with a retryable availability error when no connection is published', async () => { + const submission = submitStewardAutomationProject(action); + + 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 = submitStewardAutomationProject(action).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(submitStewardAutomationProject(action)).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 submitStewardAutomationProject(action); + paramsSlot.set(paramsWithPoke(currentPoke)); + await submitStewardAutomationProject(action); + + expect(stalePoke).toHaveBeenCalledOnce(); + expect(currentPoke).toHaveBeenCalledOnce(); + expect(currentPoke).toHaveBeenCalledWith({ + app: 'steward', + mark: 'steward-automation-action-1', + json: action, + }); + }); +}); diff --git a/packages/openclaw/src/steward-automation-adapter.ts b/packages/openclaw/src/steward-automation-adapter.ts new file mode 100644 index 0000000000..cb7ef5eace --- /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 { StewardAutomationProjectAction } 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 submitStewardAutomationProject( + action: StewardAutomationProjectAction +): Promise { + const params = apiClientParamsSlot.get(); + if (!params) { + throw new StewardAutomationConnectionUnavailableError(); + } + + await params.poke({ + app: 'steward', + mark: 'steward-automation-action-1', + json: action, + }); +} From 07a6afa7ff26b6a85864271e3c8df294358f82a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:06:10 +0800 Subject: [PATCH 30/62] openclaw: trigger steward automation rereads --- .../tasks.md | 2 +- .../steward-automation-reconciliation.test.ts | 170 ++++++++++++++++++ .../src/steward-automation-reconciliation.ts | 62 +++++++ 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/steward-automation-reconciliation.test.ts create mode 100644 packages/openclaw/src/steward-automation-reconciliation.ts diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 6acffd92b2..343dc7ec70 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -54,7 +54,7 @@ - [x] 3.2 Add a local Steward adapter that submits `%project` through the monitor-published ship connection and requires successful poke acknowledgement. -- [ ] 3.3 Trigger complete reads with disabled tasks included after +- [x] 3.3 Trigger complete reads with disabled tasks included after `gateway_start` and every `cron_changed` event using the pinned OpenClaw cron access. - [ ] 3.4 Serialize reconciliation, coalesce triggers received while 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..b3e1890687 --- /dev/null +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -0,0 +1,170 @@ +import type { + PluginHookCronChangedEvent, + PluginHookGatewayContext, + PluginHookGatewayCronJob, +} from 'openclaw/plugin-sdk/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { submitStewardAutomationProject } from './steward-automation-adapter.js'; +import { + StewardAutomationCronUnavailableError, + reconcileStewardAutomation, + registerStewardAutomationReconciliationHooks, +} from './steward-automation-reconciliation.js'; + +vi.mock('./steward-automation-adapter.js', () => ({ + submitStewardAutomationProject: vi.fn(), +})); + +type HookHandler = (event: unknown, context: unknown) => unknown; + +function createFakeHookApi() { + const handlers = new Map(); + return { + 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); + } + }, + }; +} + +function cronContext(jobs: PluginHookGatewayCronJob[]) { + const list = vi.fn().mockResolvedValue(jobs); + const context: Pick = { + getCron: () => ({ list }), + }; + return { context, list }; +} + +const jobs = [ + { + id: 'disabled-job', + agentId: 'main', + name: 'Nightly status', + enabled: false, + schedule: { kind: 'cron', expr: '0 1 * * *', tz: 'UTC' }, + payload: { kind: 'agentTurn', text: 'check status' }, + state: { lastRunStatus: 'ok', lastRunAtMs: 1_777_000_000_000 }, + createdAtMs: 1_700_000_000_000, + }, +] satisfies PluginHookGatewayCronJob[]; + +beforeEach(() => { + vi.mocked(submitStewardAutomationProject).mockReset(); + vi.mocked(submitStewardAutomationProject).mockResolvedValue(undefined); +}); + +describe('reconcileStewardAutomation', () => { + it('reads the complete list including disabled jobs and submits its normalized project', async () => { + const { context, list } = cronContext(jobs); + + await reconcileStewardAutomation(context.getCron); + + expect(list).toHaveBeenCalledOnce(); + expect(list).toHaveBeenCalledWith({ includeDisabled: true }); + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProject).toHaveBeenCalledWith({ + project: { + tasks: [ + { + id: 'disabled-job', + agentId: 'main', + name: 'Nightly status', + enabled: false, + schedule: { kind: 'cron', expr: '0 1 * * *', tz: 'UTC' }, + payload: { kind: 'agentTurn', text: 'check status' }, + createdAtMs: 1_700_000_000_000, + }, + ], + }, + }); + }); + + it('submits an empty complete project after a successful empty read', async () => { + const { context } = cronContext([]); + + await reconcileStewardAutomation(context.getCron); + + expect(submitStewardAutomationProject).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(submitStewardAutomationProject).not.toHaveBeenCalled(); + }); + + it('propagates read failures without submitting an empty project', async () => { + const readError = new Error('cron list unavailable'); + const list = vi.fn().mockRejectedValue(readError); + + await expect(reconcileStewardAutomation(() => ({ list }))).rejects.toBe( + readError + ); + expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + }); + + it('propagates submission failures for later retry', async () => { + const submissionError = new Error('poke nack'); + const { context } = cronContext(jobs); + vi.mocked(submitStewardAutomationProject).mockRejectedValue( + submissionError + ); + + await expect(reconcileStewardAutomation(context.getCron)).rejects.toBe( + submissionError + ); + }); +}); + +describe('registerStewardAutomationReconciliationHooks', () => { + it('reconciles after gateway_start', async () => { + const api = createFakeHookApi(); + const { context, list } = cronContext(jobs); + registerStewardAutomationReconciliationHooks( + api as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0] + ); + + await api.fire('gateway_start', { port: 3000 }, context); + + expect(list).toHaveBeenCalledWith({ includeDisabled: true }); + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + }); + + it.each([ + 'added', + 'updated', + 'removed', + 'started', + 'finished', + ])('reconciles after the %s cron_changed action', async (action) => { + const api = createFakeHookApi(); + const { context, list } = cronContext(jobs); + registerStewardAutomationReconciliationHooks( + api as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0] + ); + + await api.fire('cron_changed', { action, jobId: 'disabled-job' }, context); + + expect(list).toHaveBeenCalledWith({ includeDisabled: true }); + expect(submitStewardAutomationProject).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..1ddb571db8 --- /dev/null +++ b/packages/openclaw/src/steward-automation-reconciliation.ts @@ -0,0 +1,62 @@ +import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core'; +import type { + PluginHookGatewayContext, + PluginHookGatewayCronService, +} from 'openclaw/plugin-sdk/types'; + +import { submitStewardAutomationProject } from './steward-automation-adapter.js'; +import { normalizeStewardAutomationProject } from './steward-automation-projection.js'; + +type StewardAutomationCronService = Pick; + +type StewardAutomationCronAccessor = + | (() => StewardAutomationCronService | undefined) + | undefined; + +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'; + } +} + +/** Read and submit one complete snapshot from the pinned gateway cron API. */ +export async function reconcileStewardAutomation( + getCron: StewardAutomationCronAccessor +): Promise { + if (!getCron) { + throw new StewardAutomationCronUnavailableError('missing-accessor'); + } + + const cron = getCron(); + if (!cron) { + throw new StewardAutomationCronUnavailableError('missing-service'); + } + + const jobs = await cron.list({ includeDisabled: true }); + const action = normalizeStewardAutomationProject(jobs); + await submitStewardAutomationProject(action); +} + +/** + * Register complete-snapshot triggers. Errors deliberately reject the hook + * promise so task 3.5 can retry them; the eventual index wrapper owns logging + * and isolation from other hook consumers. + */ +export function registerStewardAutomationReconciliationHooks( + api: Pick +): void { + const reconcile = (ctx: Pick) => + reconcileStewardAutomation(ctx.getCron); + + api.on('gateway_start', (_event, ctx) => reconcile(ctx)); + api.on('cron_changed', (_event, ctx) => reconcile(ctx)); +} From 1c616d0ac5cef5d9686b28a52b0832bd5cfa036f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:12:01 +0800 Subject: [PATCH 31/62] openclaw: serialize steward automation reconciliation --- .../tasks.md | 2 +- .../steward-automation-reconciliation.test.ts | 142 ++++++++++++++++++ .../src/steward-automation-reconciliation.ts | 83 +++++++++- 3 files changed, 221 insertions(+), 6 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 343dc7ec70..2510fe3698 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -57,7 +57,7 @@ - [x] 3.3 Trigger complete reads with disabled tasks included after `gateway_start` and every `cron_changed` event using the pinned OpenClaw cron access. -- [ ] 3.4 Serialize reconciliation, coalesce triggers received while +- [x] 3.4 Serialize reconciliation, coalesce triggers received while busy into one follow-up, and prevent snapshots from overtaking one another. - [ ] 3.5 Retry unavailable cron reads and failed Steward diff --git a/packages/openclaw/src/steward-automation-reconciliation.test.ts b/packages/openclaw/src/steward-automation-reconciliation.test.ts index b3e1890687..d692f8c125 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.test.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { submitStewardAutomationProject } from './steward-automation-adapter.js'; import { StewardAutomationCronUnavailableError, + StewardAutomationReconciler, reconcileStewardAutomation, registerStewardAutomationReconciliationHooks, } from './steward-automation-reconciliation.js'; @@ -40,6 +41,24 @@ function cronContext(jobs: PluginHookGatewayCronJob[]) { 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', text: id }, + }; +} + const jobs = [ { id: 'disabled-job', @@ -131,6 +150,129 @@ describe('reconcileStewardAutomation', () => { }); }); +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.trigger(() => ({ 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(submitStewardAutomationProject).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProject).toHaveBeenNthCalledWith(1, { + project: { tasks: [expect.objectContaining({ id: 'first' })] }, + }); + expect(submitStewardAutomationProject).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(submitStewardAutomationProject) + .mockImplementationOnce(() => firstAcknowledgement.promise.then(() => {})) + .mockResolvedValueOnce(undefined); + const firstContext = cronContext([job('older')]); + const nextContext = cronContext([job('newer')]); + const reconciler = new StewardAutomationReconciler(); + + const first = reconciler.trigger(firstContext.context.getCron); + await vi.waitFor(() => { + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + }); + const next = reconciler.trigger(nextContext.context.getCron); + + expect(nextContext.list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + + firstAcknowledgement.resolve(undefined); + await first; + await next; + + expect(nextContext.list).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProject).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProject).toHaveBeenNthCalledWith(1, { + project: { tasks: [expect.objectContaining({ id: 'older' })] }, + }); + expect(submitStewardAutomationProject).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.trigger(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('rejects a failed batch but still drains an already-pending batch', async () => { + const firstRun = deferred(); + const secondRun = deferred(); + const reconcile = vi + .fn<() => Promise>() + .mockImplementationOnce(() => firstRun.promise) + .mockImplementationOnce(() => secondRun.promise); + const reconciler = new StewardAutomationReconciler(reconcile); + const failure = new Error('first reconciliation failed'); + let pendingSettled = false; + + const failed = reconciler.trigger(undefined); + const pending = reconciler.trigger(undefined).then(() => { + pendingSettled = true; + }); + firstRun.reject(failure); + + await expect(failed).rejects.toBe(failure); + await vi.waitFor(() => { + expect(reconcile).toHaveBeenCalledTimes(2); + }); + expect(pendingSettled).toBe(false); + + secondRun.resolve(); + await pending; + expect(pendingSettled).toBe(true); + }); +}); + describe('registerStewardAutomationReconciliationHooks', () => { it('reconciles after gateway_start', async () => { const api = createFakeHookApi(); diff --git a/packages/openclaw/src/steward-automation-reconciliation.ts b/packages/openclaw/src/steward-automation-reconciliation.ts index 1ddb571db8..71e3ce4883 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.ts @@ -9,10 +9,20 @@ import { normalizeStewardAutomationProject } from './steward-automation-projecti type StewardAutomationCronService = Pick; -type StewardAutomationCronAccessor = +export type StewardAutomationCronAccessor = | (() => StewardAutomationCronService | undefined) | undefined; +interface ReconciliationWaiter { + resolve: () => void; + reject: (error: unknown) => void; +} + +interface PendingReconciliation { + getCron: StewardAutomationCronAccessor; + waiters: ReconciliationWaiter[]; +} + export class StewardAutomationCronUnavailableError extends Error { readonly retryable = true; @@ -46,6 +56,68 @@ export async function reconcileStewardAutomation( await submitStewardAutomationProject(action); } +/** + * Serializes complete reconciliations and collapses a busy-period burst into + * one follow-up using the latest trigger's cron accessor. + * + * Each trigger promise belongs to the batch that covers that trigger. A batch + * failure rejects only that batch's promises; an already-pending follow-up is + * still drained and settles independently. This keeps failures visible without + * losing newer repair triggers and leaves retry policy to task 3.5. + */ +export class StewardAutomationReconciler { + private pending: PendingReconciliation | null = null; + private running = false; + + constructor( + private readonly reconcile: ( + getCron: StewardAutomationCronAccessor + ) => Promise = reconcileStewardAutomation + ) {} + + trigger(getCron: StewardAutomationCronAccessor): Promise { + const promise = new Promise((resolve, reject) => { + const waiter = { resolve, reject }; + if (this.pending) { + this.pending.getCron = getCron; + this.pending.waiters.push(waiter); + } else { + this.pending = { getCron, waiters: [waiter] }; + } + }); + + if (!this.running) { + this.running = true; + void this.drain(); + } + + return promise; + } + + private async drain(): Promise { + while (this.pending) { + const batch = this.pending; + this.pending = null; + + try { + await this.reconcile(batch.getCron); + for (const waiter of batch.waiters) { + waiter.resolve(); + } + } catch (error) { + for (const waiter of batch.waiters) { + waiter.reject(error); + } + } + } + + // The loop condition and this assignment execute without an await between + // them. A later trigger therefore either becomes pending before the loop + // drains or observes running=false and starts a new worker. + this.running = false; + } +} + /** * Register complete-snapshot triggers. Errors deliberately reject the hook * promise so task 3.5 can retry them; the eventual index wrapper owns logging @@ -54,9 +126,10 @@ export async function reconcileStewardAutomation( export function registerStewardAutomationReconciliationHooks( api: Pick ): void { - const reconcile = (ctx: Pick) => - reconcileStewardAutomation(ctx.getCron); + const reconciler = new StewardAutomationReconciler(); + const trigger = (ctx: Pick) => + reconciler.trigger(ctx.getCron); - api.on('gateway_start', (_event, ctx) => reconcile(ctx)); - api.on('cron_changed', (_event, ctx) => reconcile(ctx)); + api.on('gateway_start', (_event, ctx) => trigger(ctx)); + api.on('cron_changed', (_event, ctx) => trigger(ctx)); } From 5b5d5aacc396694acd354664f82227b7a13b88a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:20:00 +0800 Subject: [PATCH 32/62] openclaw: retry steward automation reconciliation --- .../tasks.md | 2 +- .../steward-automation-reconciliation.test.ts | 182 +++++++++++++++++- .../src/steward-automation-reconciliation.ts | 101 ++++++++-- 3 files changed, 254 insertions(+), 31 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 2510fe3698..e9e7639dbe 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -60,7 +60,7 @@ - [x] 3.4 Serialize reconciliation, coalesce triggers received while busy into one follow-up, and prevent snapshots from overtaking one another. -- [ ] 3.5 Retry unavailable cron reads and failed Steward +- [x] 3.5 Retry unavailable cron reads and failed Steward submissions while the gateway remains active, preserving the last successful projection. - [ ] 3.6 Stop new reconciliation and retry activity on diff --git a/packages/openclaw/src/steward-automation-reconciliation.test.ts b/packages/openclaw/src/steward-automation-reconciliation.test.ts index d692f8c125..2f9ecdd4f0 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.test.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { submitStewardAutomationProject } from './steward-automation-adapter.js'; import { + DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS, StewardAutomationCronUnavailableError, StewardAutomationReconciler, reconcileStewardAutomation, @@ -59,6 +60,17 @@ function job(id: string): PluginHookGatewayCronJob { }; } +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', @@ -244,33 +256,183 @@ describe('StewardAutomationReconciler', () => { expect(secondSettled).toBe(true); }); - it('rejects a failed batch but still drains an already-pending batch', async () => { + 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); - const failure = new Error('first reconciliation failed'); + const reconciler = new StewardAutomationReconciler(reconcile, delay); + let firstSettled = false; let pendingSettled = false; - const failed = reconciler.trigger(undefined); + const first = reconciler.trigger(undefined).then(() => { + firstSettled = true; + }); const pending = reconciler.trigger(undefined).then(() => { pendingSettled = true; }); - firstRun.reject(failure); + firstRun.reject(new Error('first reconciliation failed')); - await expect(failed).rejects.toBe(failure); - await vi.waitFor(() => { - expect(reconcile).toHaveBeenCalledTimes(2); - }); + 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 pending; + 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.trigger(unavailable); + await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); + const repair = reconciler.trigger(recovered.context.getCron); + + expect(recovered.list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + waits[0].resolve(); + await Promise.all([initial, repair]); + + expect(recovered.list).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + } + ); + + 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.trigger(() => ({ list })); + await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); + expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + + waits[0].resolve(); + await result; + expect(list).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProject).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(submitStewardAutomationProject) + .mockRejectedValueOnce(new Error('poke nack')) + .mockResolvedValueOnce(undefined); + const reconciler = new StewardAutomationReconciler( + reconcileStewardAutomation, + delay + ); + + const result = reconciler.trigger(() => ({ list })); + await vi.waitFor(() => expect(delay).toHaveBeenCalledTimes(1)); + expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + waits[0].resolve(); + + await vi.waitFor(() => expect(delay).toHaveBeenCalledTimes(2)); + expect(list).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + waits[1].resolve(); + + await result; + expect(list).toHaveBeenCalledTimes(3); + expect(submitStewardAutomationProject).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProject).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.trigger(() => ({ 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(submitStewardAutomationProject).toHaveBeenCalledOnce(); + }); + + it('submits an empty project 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.trigger(() => ({ list })); + await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); + expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + + waits[0].resolve(); + await result; + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProject).toHaveBeenCalledWith({ + project: { tasks: [] }, + }); + }); }); describe('registerStewardAutomationReconciliationHooks', () => { diff --git a/packages/openclaw/src/steward-automation-reconciliation.ts b/packages/openclaw/src/steward-automation-reconciliation.ts index 71e3ce4883..b7b5c55da7 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.ts @@ -23,6 +23,31 @@ interface PendingReconciliation { waiters: ReconciliationWaiter[]; } +export const DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS = 5_000; + +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; @@ -60,10 +85,10 @@ export async function reconcileStewardAutomation( * Serializes complete reconciliations and collapses a busy-period burst into * one follow-up using the latest trigger's cron accessor. * - * Each trigger promise belongs to the batch that covers that trigger. A batch - * failure rejects only that batch's promises; an already-pending follow-up is - * still drained and settles independently. This keeps failures visible without - * losing newer repair triggers and leaves retry policy to task 3.5. + * Failed attempts wait once before retrying the complete read, normalization, + * and submission. Triggers received during that delay do not wake it early; + * they join the failed batch, and the next attempt uses the latest accessor. + * Every joined trigger remains pending until that covering snapshot succeeds. */ export class StewardAutomationReconciler { private pending: PendingReconciliation | null = null; @@ -72,7 +97,10 @@ export class StewardAutomationReconciler { constructor( private readonly reconcile: ( getCron: StewardAutomationCronAccessor - ) => Promise = reconcileStewardAutomation + ) => Promise = reconcileStewardAutomation, + private readonly retryDelay: StewardAutomationRetryDelay = waitForRetryDelay, + private readonly retryDelayMs = DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS, + private readonly retrySignal?: AbortSignal ) {} trigger(getCron: StewardAutomationCronAccessor): Promise { @@ -95,18 +123,39 @@ export class StewardAutomationReconciler { } private async drain(): Promise { - while (this.pending) { - const batch = this.pending; - this.pending = null; - - try { - await this.reconcile(batch.getCron); - for (const waiter of batch.waiters) { - waiter.resolve(); - } - } catch (error) { - for (const waiter of batch.waiters) { - waiter.reject(error); + for (;;) { + const batch = this.takePending(); + if (!batch) { + break; + } + + for (;;) { + try { + await this.reconcile(batch.getCron); + for (const waiter of batch.waiters) { + waiter.resolve(); + } + break; + } catch { + try { + await this.retryDelay(this.retryDelayMs, this.retrySignal); + } catch (delayError) { + this.rejectBatch(batch, delayError); + const pending = this.takePending(); + if (pending) { + this.rejectBatch(pending, delayError); + } + break; + } + + // Wait for the single scheduled delay even if more triggers arrive. + // They then join this retry, so no stale intermediate accessor is + // read and all covered promises settle with the successful retry. + const pending = this.takePending(); + if (pending) { + batch.getCron = pending.getCron; + batch.waiters.push(...pending.waiters); + } } } } @@ -116,12 +165,24 @@ export class StewardAutomationReconciler { // drains or observes running=false and starts a new worker. this.running = false; } + + private takePending(): PendingReconciliation | null { + const pending = this.pending; + this.pending = null; + return pending; + } + + private rejectBatch(batch: PendingReconciliation, error: unknown): void { + for (const waiter of batch.waiters) { + waiter.reject(error); + } + } } /** - * Register complete-snapshot triggers. Errors deliberately reject the hook - * promise so task 3.5 can retry them; the eventual index wrapper owns logging - * and isolation from other hook consumers. + * Register complete-snapshot triggers. Each hook promise remains pending + * across retryable reconciliation failures and resolves after delivery; the + * eventual index wrapper owns logging and isolation from other hook consumers. */ export function registerStewardAutomationReconciliationHooks( api: Pick From bae3552674ed61752ca8de992aa97b6f932cada6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:27:02 +0800 Subject: [PATCH 33/62] openclaw: stop steward automation reconciliation --- .../tasks.md | 2 +- .../steward-automation-reconciliation.test.ts | 184 ++++++++++++- .../src/steward-automation-reconciliation.ts | 248 ++++++++++++++---- 3 files changed, 370 insertions(+), 64 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index e9e7639dbe..18347fb826 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -63,7 +63,7 @@ - [x] 3.5 Retry unavailable cron reads and failed Steward submissions while the gateway remains active, preserving the last successful projection. -- [ ] 3.6 Stop new reconciliation and retry activity on +- [x] 3.6 Stop new reconciliation and retry activity on `gateway_stop` without clearing the durable Steward snapshot. - [ ] 3.7 Replace the temporary diagnostic handler with projection registration while keeping cron telemetry failures isolated. diff --git a/packages/openclaw/src/steward-automation-reconciliation.test.ts b/packages/openclaw/src/steward-automation-reconciliation.test.ts index 2f9ecdd4f0..871076aeeb 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.test.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -10,6 +10,7 @@ import { DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS, StewardAutomationCronUnavailableError, StewardAutomationReconciler, + StewardAutomationReconciliationCancelledError, reconcileStewardAutomation, registerStewardAutomationReconciliationHooks, } from './steward-automation-reconciliation.js'; @@ -170,7 +171,7 @@ describe('StewardAutomationReconciler', () => { const list3 = vi.fn().mockResolvedValue([job('latest-follow-up')]); const reconciler = new StewardAutomationReconciler(); - const first = reconciler.trigger(() => ({ list: list1 })); + const first = reconciler.start(() => ({ list: list1 })); const stale = Array.from({ length: 8 }, () => reconciler.trigger(() => ({ list: list2 })) ); @@ -207,7 +208,7 @@ describe('StewardAutomationReconciler', () => { const nextContext = cronContext([job('newer')]); const reconciler = new StewardAutomationReconciler(); - const first = reconciler.trigger(firstContext.context.getCron); + const first = reconciler.start(firstContext.context.getCron); await vi.waitFor(() => { expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); }); @@ -239,7 +240,7 @@ describe('StewardAutomationReconciler', () => { const reconciler = new StewardAutomationReconciler(reconcile); let secondSettled = false; - const first = reconciler.trigger(undefined); + const first = reconciler.start(undefined); const second = first.then(() => reconciler.trigger(undefined).then(() => { secondSettled = true; @@ -268,7 +269,7 @@ describe('StewardAutomationReconciler', () => { let firstSettled = false; let pendingSettled = false; - const first = reconciler.trigger(undefined).then(() => { + const first = reconciler.start(undefined).then(() => { firstSettled = true; }); const pending = reconciler.trigger(undefined).then(() => { @@ -305,7 +306,7 @@ describe('StewardAutomationReconciler', () => { delay ); - const initial = reconciler.trigger(unavailable); + const initial = reconciler.start(unavailable); await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); const repair = reconciler.trigger(recovered.context.getCron); @@ -330,7 +331,7 @@ describe('StewardAutomationReconciler', () => { delay ); - const result = reconciler.trigger(() => ({ list })); + const result = reconciler.start(() => ({ list })); await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); expect(submitStewardAutomationProject).not.toHaveBeenCalled(); @@ -363,7 +364,7 @@ describe('StewardAutomationReconciler', () => { delay ); - const result = reconciler.trigger(() => ({ list })); + const result = reconciler.start(() => ({ list })); await vi.waitFor(() => expect(delay).toHaveBeenCalledTimes(1)); expect(submitStewardAutomationProject).not.toHaveBeenCalled(); waits[0].resolve(); @@ -392,7 +393,7 @@ describe('StewardAutomationReconciler', () => { delay ); - const initial = reconciler.trigger(() => ({ list: firstList })); + const initial = reconciler.start(() => ({ list: firstList })); const stale = Array.from({ length: 6 }, () => reconciler.trigger(() => ({ list: staleList })) ); @@ -422,7 +423,7 @@ describe('StewardAutomationReconciler', () => { delay ); - const result = reconciler.trigger(() => ({ list })); + const result = reconciler.start(() => ({ list })); await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); expect(submitStewardAutomationProject).not.toHaveBeenCalled(); @@ -433,9 +434,170 @@ describe('StewardAutomationReconciler', () => { project: { tasks: [] }, }); }); + + 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(submitStewardAutomationProject).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(submitStewardAutomationProject).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(submitStewardAutomationProject).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(submitStewardAutomationProject).not.toHaveBeenCalled(); + }); + + 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(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProject).toHaveBeenCalledWith({ + project: { tasks: [expect.objectContaining({ id: 'fresh' })] }, + }); + }); }); describe('registerStewardAutomationReconciliationHooks', () => { + it('registers gateway_stop and ignores cron changes while inactive', async () => { + const api = createFakeHookApi(); + const { context, list } = cronContext(jobs); + registerStewardAutomationReconciliationHooks( + api as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0] + ); + + 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 api.fire('gateway_stop', { reason: 'shutdown' }, context); + list.mockClear(); + vi.mocked(submitStewardAutomationProject).mockClear(); + + await api.fire( + 'cron_changed', + { action: 'removed', jobId: 'disabled-job' }, + context + ); + expect(list).not.toHaveBeenCalled(); + expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + }); + it('reconciles after gateway_start', async () => { const api = createFakeHookApi(); const { context, list } = cronContext(jobs); @@ -466,6 +628,10 @@ describe('registerStewardAutomationReconciliationHooks', () => { >[0] ); + await api.fire('gateway_start', { port: 3000 }, context); + list.mockClear(); + vi.mocked(submitStewardAutomationProject).mockClear(); + await api.fire('cron_changed', { action, jobId: 'disabled-job' }, context); expect(list).toHaveBeenCalledWith({ includeDisabled: true }); diff --git a/packages/openclaw/src/steward-automation-reconciliation.ts b/packages/openclaw/src/steward-automation-reconciliation.ts index b7b5c55da7..1c4d18c6a2 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.ts @@ -13,14 +13,24 @@ export type StewardAutomationCronAccessor = | (() => StewardAutomationCronService | undefined) | undefined; +type StewardAutomationSubmissionGuard = () => void | Promise; + +type StewardAutomationReconciliation = ( + getCron: StewardAutomationCronAccessor, + beforeSubmit?: StewardAutomationSubmissionGuard, + assertCanSubmit?: () => void +) => Promise; + interface ReconciliationWaiter { resolve: () => void; reject: (error: unknown) => void; } interface PendingReconciliation { + epoch: number; getCron: StewardAutomationCronAccessor; waiters: ReconciliationWaiter[]; + settled: boolean; } export const DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS = 5_000; @@ -63,9 +73,26 @@ export class StewardAutomationCronUnavailableError extends Error { } } +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 + getCron: StewardAutomationCronAccessor, + beforeSubmit?: StewardAutomationSubmissionGuard, + assertCanSubmit?: () => void ): Promise { if (!getCron) { throw new StewardAutomationCronUnavailableError('missing-accessor'); @@ -78,39 +105,75 @@ export async function reconcileStewardAutomation( const jobs = await cron.list({ includeDisabled: true }); const action = normalizeStewardAutomationProject(jobs); + await beforeSubmit?.(); + // Keep this synchronous check adjacent to invoking the adapter. Awaiting a + // lifecycle guard here would reopen a microtask-sized stale-submit race. + assertCanSubmit?.(); await submitStewardAutomationProject(action); } /** - * Serializes complete reconciliations and collapses a busy-period burst into - * one follow-up using the latest trigger's cron accessor. + * Owns serialized reconciliation for reusable gateway lifecycle epochs. * - * Failed attempts wait once before retrying the complete read, normalization, - * and submission. Triggers received during that delay do not wake it early; - * they join the failed batch, and the next attempt uses the latest accessor. - * Every joined trigger remains pending until that covering snapshot succeeds. + * `start` replaces any active epoch and requests its full snapshot. Triggers + * received while active are coalesced. `stop` cancels retry delay, rejects + * outstanding promises with a typed cancellation, and leaves durable Steward + * state untouched. 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 retryController: AbortController | null = null; constructor( - private readonly reconcile: ( - getCron: StewardAutomationCronAccessor - ) => Promise = reconcileStewardAutomation, + private readonly reconcile: StewardAutomationReconciliation = reconcileStewardAutomation, private readonly retryDelay: StewardAutomationRetryDelay = waitForRetryDelay, - private readonly retryDelayMs = DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS, - private readonly retrySignal?: AbortSignal + private readonly retryDelayMs = DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS ) {} + start(getCron: StewardAutomationCronAccessor): Promise { + if (this.activeEpoch !== null) { + this.deactivate('gateway-restart'); + } + + const epoch = ++this.epoch; + this.activeEpoch = epoch; + this.retryController = new AbortController(); + return this.enqueue(epoch, getCron); + } + + /** Ignore cron changes safely while no gateway epoch is active. */ trigger(getCron: StewardAutomationCronAccessor): Promise { + if (this.activeEpoch === null) { + return Promise.resolve(); + } + return this.enqueue(this.activeEpoch, getCron); + } + + stop(): void { + this.deactivate('gateway-stop'); + } + + private enqueue( + epoch: number, + getCron: StewardAutomationCronAccessor + ): Promise { const promise = new Promise((resolve, reject) => { const waiter = { resolve, reject }; - if (this.pending) { + if (this.pending?.epoch === epoch) { this.pending.getCron = getCron; this.pending.waiters.push(waiter); } else { - this.pending = { getCron, waiters: [waiter] }; + this.pending = { + epoch, + getCron, + waiters: [waiter], + settled: false, + }; } }); @@ -122,48 +185,113 @@ export class StewardAutomationReconciler { return promise; } - private async drain(): Promise { - for (;;) { - const batch = this.takePending(); - if (!batch) { - break; + private deactivate(reason: 'gateway-stop' | 'gateway-restart'): void { + const epoch = this.activeEpoch; + if (epoch === null) { + return; + } + + const cancellation = new StewardAutomationReconciliationCancelledError( + epoch, + reason + ); + this.activeEpoch = null; + this.retryController?.abort(cancellation); + this.retryController = 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 async drain(): Promise { + try { for (;;) { - try { - await this.reconcile(batch.getCron); - for (const waiter of batch.waiters) { - waiter.resolve(); - } + const batch = this.takePending(); + if (!batch) { break; - } catch { - try { - await this.retryDelay(this.retryDelayMs, this.retrySignal); - } catch (delayError) { - this.rejectBatch(batch, delayError); - const pending = this.takePending(); - if (pending) { - this.rejectBatch(pending, delayError); - } + } + this.current = batch; + + for (;;) { + if (!this.isActiveEpoch(batch.epoch)) { + this.rejectBatch(batch, this.cancellationFor(batch.epoch)); break; } - // Wait for the single scheduled delay even if more triggers arrive. - // They then join this retry, so no stale intermediate accessor is - // read and all covered promises settle with the successful retry. - const pending = this.takePending(); - if (pending) { - batch.getCron = pending.getCron; - batch.waiters.push(...pending.waiters); + try { + await this.reconcile(batch.getCron, undefined, () => + this.assertActiveEpoch(batch.epoch) + ); + 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, + this.retryController?.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; } + } - // The loop condition and this assignment execute without an await between - // them. A later trigger therefore either becomes pending before the loop - // drains or observes running=false and starts a new 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 { @@ -172,25 +300,37 @@ export class StewardAutomationReconciler { 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); } } } -/** - * Register complete-snapshot triggers. Each hook promise remains pending - * across retryable reconciliation failures and resolves after delivery; the - * eventual index wrapper owns logging and isolation from other hook consumers. - */ +/** Register full-snapshot work against explicit gateway lifecycle hooks. */ export function registerStewardAutomationReconciliationHooks( api: Pick -): void { +): StewardAutomationReconciler { const reconciler = new StewardAutomationReconciler(); - const trigger = (ctx: Pick) => - reconciler.trigger(ctx.getCron); + const getCron = (ctx: Pick) => + ctx.getCron; - api.on('gateway_start', (_event, ctx) => trigger(ctx)); - api.on('cron_changed', (_event, ctx) => trigger(ctx)); + api.on('gateway_start', (_event, ctx) => reconciler.start(getCron(ctx))); + api.on('cron_changed', (_event, ctx) => reconciler.trigger(getCron(ctx))); + api.on('gateway_stop', () => reconciler.stop()); + return reconciler; } From fbc6957b420702a44e81a24c188dab3a8f829642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:35:09 +0800 Subject: [PATCH 34/62] openclaw: register steward automation projection --- .../tasks.md | 2 +- packages/openclaw/index.ts | 6 +- .../steward-automation-reconciliation.test.ts | 211 +++++++++++++++++- .../src/steward-automation-reconciliation.ts | 99 +++++++- 4 files changed, 300 insertions(+), 18 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 18347fb826..2388c8a44b 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -65,7 +65,7 @@ last successful projection. - [x] 3.6 Stop new reconciliation and retry activity on `gateway_stop` without clearing the durable Steward snapshot. -- [ ] 3.7 Replace the temporary diagnostic handler with projection +- [x] 3.7 Replace the temporary diagnostic handler with projection registration while keeping cron telemetry failures isolated. ## 4. Projection Verification diff --git a/packages/openclaw/index.ts b/packages/openclaw/index.ts index b7e9d80acd..6e683a380a 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,9 +1367,8 @@ export default defineBundledChannelEntry({ } }); - api.on('cron_changed', async (event, ctx) => { - api.logger.info('[steward auto] testing'); - api.logger.info(`[steward auto] ${JSON.stringify(event, null, 2)}`); + registerStewardAutomationReconciliationHooks(api, { + logger: { warn: (message) => api.logger.warn(message) }, }); if (shouldInstallTlonDiagnosticSubscriptions(api.registrationMode)) { diff --git a/packages/openclaw/src/steward-automation-reconciliation.test.ts b/packages/openclaw/src/steward-automation-reconciliation.test.ts index 871076aeeb..9cb9483952 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.test.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -3,7 +3,7 @@ import type { PluginHookGatewayContext, PluginHookGatewayCronJob, } from 'openclaw/plugin-sdk/types'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { submitStewardAutomationProject } from './steward-automation-adapter.js'; import { @@ -11,8 +11,10 @@ import { StewardAutomationCronUnavailableError, StewardAutomationReconciler, StewardAutomationReconciliationCancelledError, + getStewardAutomationReconciler, reconcileStewardAutomation, registerStewardAutomationReconciliationHooks, + setStewardAutomationReconciler, } from './steward-automation-reconciliation.js'; vi.mock('./steward-automation-adapter.js', () => ({ @@ -86,10 +88,17 @@ const jobs = [ ] satisfies PluginHookGatewayCronJob[]; beforeEach(() => { + getStewardAutomationReconciler()?.stop(); + setStewardAutomationReconciler(null); vi.mocked(submitStewardAutomationProject).mockReset(); vi.mocked(submitStewardAutomationProject).mockResolvedValue(undefined); }); +afterEach(() => { + getStewardAutomationReconciler()?.stop(); + setStewardAutomationReconciler(null); +}); + describe('reconcileStewardAutomation', () => { it('reads the complete list including disabled jobs and submits its normalized project', async () => { const { context, list } = cronContext(jobs); @@ -567,13 +576,17 @@ describe('StewardAutomationReconciler', () => { }); describe('registerStewardAutomationReconciliationHooks', () => { + const registrationOptions = () => ({ + logger: { warn: vi.fn() }, + }); it('registers gateway_stop and ignores cron changes while inactive', async () => { const api = createFakeHookApi(); const { context, list } = cronContext(jobs); registerStewardAutomationReconciliationHooks( api as unknown as Parameters< typeof registerStewardAutomationReconciliationHooks - >[0] + >[0], + registrationOptions() ); expect(api.on).toHaveBeenCalledWith('gateway_stop', expect.any(Function)); @@ -585,6 +598,9 @@ describe('registerStewardAutomationReconciliationHooks', () => { expect(list).not.toHaveBeenCalled(); await api.fire('gateway_start', { port: 3000 }, context); + await vi.waitFor(() => { + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + }); await api.fire('gateway_stop', { reason: 'shutdown' }, context); list.mockClear(); vi.mocked(submitStewardAutomationProject).mockClear(); @@ -604,13 +620,16 @@ describe('registerStewardAutomationReconciliationHooks', () => { registerStewardAutomationReconciliationHooks( api as unknown as Parameters< typeof registerStewardAutomationReconciliationHooks - >[0] + >[0], + registrationOptions() ); await api.fire('gateway_start', { port: 3000 }, context); + await vi.waitFor(() => { + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + }); expect(list).toHaveBeenCalledWith({ includeDisabled: true }); - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); }); it.each([ @@ -625,16 +644,198 @@ describe('registerStewardAutomationReconciliationHooks', () => { registerStewardAutomationReconciliationHooks( api as unknown as Parameters< typeof registerStewardAutomationReconciliationHooks - >[0] + >[0], + registrationOptions() ); await api.fire('gateway_start', { port: 3000 }, context); + await vi.waitFor(() => { + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + }); list.mockClear(); vi.mocked(submitStewardAutomationProject).mockClear(); await api.fire('cron_changed', { action, jobId: 'disabled-job' }, context); + await vi.waitFor(() => { + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + }); expect(list).toHaveBeenCalledWith({ includeDisabled: true }); + }); + + 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 as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0], + options + ); + const full = registerStewardAutomationReconciliationHooks( + fullApi as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0], + 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(submitStewardAutomationProject).toHaveBeenCalledOnce(); + }); + + const prewarm = registerStewardAutomationReconciliationHooks( + prewarmApi as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0], + 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(submitStewardAutomationProject).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(submitStewardAutomationProject).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 as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0], + options + ); + registerStewardAutomationReconciliationHooks( + api2 as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0], + options + ); + const initial = cronContext([job('initial')]); + const duplicate = cronContext([job('duplicate')]); + + await api1.fire('gateway_start', { port: 3000 }, initial.context); + await vi.waitFor(() => { + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + }); + await api2.fire('gateway_start', { port: 3000 }, duplicate.context); + await Promise.resolve(); + + expect(initial.list).toHaveBeenCalledOnce(); + expect(duplicate.list).not.toHaveBeenCalled(); expect(submitStewardAutomationProject).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 as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0], + options + ); + + await api.fire( + 'gateway_start', + { port: 3000 }, + { + getCron: () => ({ list }), + } + ); + + expect(list).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProject).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 as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0], + 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('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 as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0], + { logger: { warn } } + ); + + 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 index 1c4d18c6a2..969518e0c2 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.ts @@ -4,6 +4,7 @@ import type { PluginHookGatewayCronService, } from 'openclaw/plugin-sdk/types'; +import { sharedSlot } from './shared-state.js'; import { submitStewardAutomationProject } from './steward-automation-adapter.js'; import { normalizeStewardAutomationProject } from './steward-automation-projection.js'; @@ -115,8 +116,9 @@ export async function reconcileStewardAutomation( /** * Owns serialized reconciliation for reusable gateway lifecycle epochs. * - * `start` replaces any active epoch and requests its full snapshot. Triggers - * received while active are coalesced. `stop` cancels retry delay, rejects + * `start` creates an epoch and requests its full snapshot, while duplicate + * starts during that epoch are ignored. Active triggers are coalesced. `stop` + * cancels retry delay, rejects * outstanding promises with a typed cancellation, and leaves durable Steward * state untouched. A stopped reconciler ignores later change triggers until a * new `start` creates a fresh epoch. @@ -136,8 +138,11 @@ export class StewardAutomationReconciler { ) {} 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) { - this.deactivate('gateway-restart'); + return Promise.resolve(); } const epoch = ++this.epoch; @@ -321,16 +326,92 @@ export class StewardAutomationReconciler { } } -/** Register full-snapshot work against explicit gateway lifecycle hooks. */ +const reconcilerSlot = sharedSlot( + 'stewardAutomation.reconciler' +); + +export function getStewardAutomationReconciler(): StewardAutomationReconciler | null { + return reconcilerSlot.get(); +} + +export function setStewardAutomationReconciler( + reconciler: StewardAutomationReconciler | null +): void { + reconcilerSlot.set(reconciler); +} + +export interface RegisterStewardAutomationReconciliationHooksOptions { + logger: { warn: (message: string) => void }; +} + +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 + api: Pick, + options: RegisterStewardAutomationReconciliationHooksOptions ): StewardAutomationReconciler { - const reconciler = new StewardAutomationReconciler(); + const reconciler = + getStewardAutomationReconciler() ?? + (() => { + const created = new StewardAutomationReconciler(); + setStewardAutomationReconciler(created); + return created; + })(); const getCron = (ctx: Pick) => ctx.getCron; - api.on('gateway_start', (_event, ctx) => reconciler.start(getCron(ctx))); - api.on('cron_changed', (_event, ctx) => reconciler.trigger(getCron(ctx))); - api.on('gateway_stop', () => reconciler.stop()); + api.on('gateway_start', (_event, ctx) => { + observeProjectionWork(reconciler.start(getCron(ctx)), options.logger); + }); + api.on('cron_changed', (_event, ctx) => { + observeProjectionWork(reconciler.trigger(getCron(ctx)), options.logger); + }); + api.on('gateway_stop', () => { + reconciler.stop(); + }); return reconciler; } From 282058ba3c2a10c88d3eff626d698371e2a6220e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:37:46 +0800 Subject: [PATCH 35/62] openclaw: verify steward automation normalization --- .../tasks.md | 2 +- .../src/steward-automation-projection.test.ts | 23 +++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 2388c8a44b..9ac450be1a 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -70,7 +70,7 @@ ## 4. Projection Verification -- [ ] 4.1 Using captured OpenClaw `getCron().list()` trace fixtures, +- [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. diff --git a/packages/openclaw/src/steward-automation-projection.test.ts b/packages/openclaw/src/steward-automation-projection.test.ts index 648c692b7a..42080bc224 100644 --- a/packages/openclaw/src/steward-automation-projection.test.ts +++ b/packages/openclaw/src/steward-automation-projection.test.ts @@ -12,7 +12,7 @@ function runtimeJob(value: unknown): HookCronJob { } describe('Steward automation projection normalization', () => { - it('normalizes captured jobs and omits execution-only fields', () => { + it('normalizes captured at/every jobs with their optional fields', () => { const result = normalizeStewardAutomationProject( capturedCronJobs.map(runtimeJob) ); @@ -57,12 +57,17 @@ describe('Steward automation projection normalization', () => { ], }, }); - expect(JSON.stringify(result)).not.toMatch( - /state|delivery|deleteAfterRun|message/ - ); + for (const task of result.project.tasks) { + expect(task).not.toHaveProperty('description'); + 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('message'); + } }); - it('preserves false and zero and prefers declared payload text', () => { + it('includes a synthetic disabled cron job and preserves false and zero', () => { const result = normalizeStewardAutomationProject([ runtimeJob({ id: 'disabled-zero', @@ -81,6 +86,8 @@ describe('Steward automation projection normalization', () => { }, createdAtMs: 0, updatedAtMs: 0, + deleteAfterRun: false, + delivery: { mode: 'announce', to: '~sample' }, sessionKey: 'drop me', state: { nextRunAtMs: 1 }, }), @@ -100,6 +107,12 @@ describe('Steward automation projection normalization', () => { ], }, }); + 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('message'); }); it('preserves input order and returns a complete empty project', () => { From 05fe9ebc94d707503b28f15c6f9daa13ab40bce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:39:49 +0800 Subject: [PATCH 36/62] openclaw: verify automation startup recovery --- .../tasks.md | 2 +- .../steward-automation-reconciliation.test.ts | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 9ac450be1a..7c81cecacc 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -74,7 +74,7 @@ test normalization of optional fields and all supported schedules, inclusion of disabled tasks, and omission of cron job state. -- [ ] 4.2 Test startup reconciliation when cron access is ready, +- [x] 4.2 Test startup reconciliation when cron access is ready, temporarily unavailable, empty, and restored after a stale period. - [ ] 4.3 Test complete rereads after definition-related and diff --git a/packages/openclaw/src/steward-automation-reconciliation.test.ts b/packages/openclaw/src/steward-automation-reconciliation.test.ts index 9cb9483952..ba61a9a71a 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.test.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -329,6 +329,53 @@ describe('StewardAutomationReconciler', () => { } ); + 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(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProject).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(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProject).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(submitStewardAutomationProject).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProject).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 From ba6e7a60a5521f1919b0aff850d9f2ca07649056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:42:07 +0800 Subject: [PATCH 37/62] openclaw: verify automation event rereads --- .../tasks.md | 2 +- .../steward-automation-reconciliation.test.ts | 109 +++++++++++++----- 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 7c81cecacc..3a5634dd24 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -77,7 +77,7 @@ - [x] 4.2 Test startup reconciliation when cron access is ready, temporarily unavailable, empty, and restored after a stale period. -- [ ] 4.3 Test complete rereads after definition-related and +- [x] 4.3 Test complete rereads after definition-related and execution-related `cron_changed` events. - [ ] 4.4 Test serialized delivery, coalesced triggers, trigger arrival during submission, and the worker-exit race. diff --git a/packages/openclaw/src/steward-automation-reconciliation.test.ts b/packages/openclaw/src/steward-automation-reconciliation.test.ts index ba61a9a71a..eb223578e4 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.test.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -679,36 +679,91 @@ describe('registerStewardAutomationReconciliationHooks', () => { expect(list).toHaveBeenCalledWith({ includeDisabled: true }); }); - it.each([ - 'added', - 'updated', - 'removed', - 'started', - 'finished', - ])('reconciles after the %s cron_changed action', async (action) => { - const api = createFakeHookApi(); - const { context, list } = cronContext(jobs); - registerStewardAutomationReconciliationHooks( - api as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], - registrationOptions() - ); + 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', text: 'first in complete list' }, + }, + { + id: 'complete-disabled', + enabled: false, + payload: { kind: 'agentTurn', text: 'second in complete list' }, + }, + ] satisfies PluginHookGatewayCronJob[]; + const { context, list } = cronContext(completeJobs); + registerStewardAutomationReconciliationHooks( + api as unknown as Parameters< + typeof registerStewardAutomationReconciliationHooks + >[0], + registrationOptions() + ); - await api.fire('gateway_start', { port: 3000 }, context); - await vi.waitFor(() => { - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); - }); - list.mockClear(); - vi.mocked(submitStewardAutomationProject).mockClear(); + await api.fire('gateway_start', { port: 3000 }, context); + await vi.waitFor(() => { + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + }); + list.mockClear(); + vi.mocked(submitStewardAutomationProject).mockClear(); + + await api.fire( + 'cron_changed', + { + action, + jobId: 'event-only', + job: { + id: 'event-only', + enabled: true, + payload: { kind: 'agentTurn', text: 'event delta' }, + state: { lastRunStatus: 'ok' }, + }, + }, + context + ); + await vi.waitFor(() => { + expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + }); - await api.fire('cron_changed', { action, jobId: 'disabled-job' }, context); - await vi.waitFor(() => { + expect(list).toHaveBeenCalledOnce(); + expect(list).toHaveBeenCalledWith({ includeDisabled: true }); expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); - }); - - expect(list).toHaveBeenCalledWith({ includeDisabled: true }); - }); + expect(submitStewardAutomationProject).toHaveBeenCalledWith({ + project: { + tasks: [ + { + id: 'complete-enabled', + enabled: true, + payload: { + kind: 'agentTurn', + text: 'first in complete list', + }, + }, + { + id: 'complete-disabled', + enabled: false, + payload: { + kind: 'agentTurn', + text: 'second in complete list', + }, + }, + ], + }, + }); + } + ); it('reuses one reconciler across discovery, full, and prewarm registries', async () => { const discoveryApi = createFakeHookApi(); From 4479c1ba9ae4474dcfc54ed268799b50ed012555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:42:34 +0800 Subject: [PATCH 38/62] openspec: record automation race verification --- .../changes/mirror-openclaw-automations-to-steward/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 3a5634dd24..d4eedb0668 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -79,7 +79,7 @@ period. - [x] 4.3 Test complete rereads after definition-related and execution-related `cron_changed` events. -- [ ] 4.4 Test serialized delivery, coalesced triggers, trigger +- [x] 4.4 Test serialized delivery, coalesced triggers, trigger arrival during submission, and the worker-exit race. - [ ] 4.5 Test read failures, submission failures, retry behavior, acknowledgement failures, and gateway shutdown. From 3985afc512774d96e1e0ea8613d97c0e5a74739e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:43:04 +0800 Subject: [PATCH 39/62] openspec: record automation failure verification --- .../changes/mirror-openclaw-automations-to-steward/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index d4eedb0668..be631e46be 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -81,7 +81,7 @@ execution-related `cron_changed` events. - [x] 4.4 Test serialized delivery, coalesced triggers, trigger arrival during submission, and the worker-exit race. -- [ ] 4.5 Test read failures, submission failures, retry behavior, +- [x] 4.5 Test read failures, submission failures, retry behavior, acknowledgement failures, and gateway shutdown. - [ ] 4.6 Add ship-level verification that additions, updates, removals, disabled tasks, and restart reconciliation appear in From e4e270b86cafc3ba0e7312f4444bde0e8e6ab9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:52:50 +0800 Subject: [PATCH 40/62] steward: verify automation reconciliation scries --- desk/tests/app/steward.hoon | 39 +++++++++++++++++++ .../tasks.md | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 78e12661da..50566466cb 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -63,6 +63,18 @@ ++ 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","text":"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","text":"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","text":"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","text":"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","text":"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","text":"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","text":"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","text":"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","text":"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","text":"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 @@ -345,6 +357,33 @@ =/ 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-to-json: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) +:: ++ test-automation-tasks-scry-rejects-foreign %- eval-mare =/ m (mare ,~) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index be631e46be..351c1288f4 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -83,7 +83,7 @@ arrival during submission, and the worker-exit race. - [x] 4.5 Test read failures, submission failures, retry behavior, acknowledgement failures, and gateway shutdown. -- [ ] 4.6 Add ship-level verification that additions, updates, +- [x] 4.6 Add ship-level verification that additions, updates, removals, disabled tasks, and restart reconciliation appear in the automation JSON scry. From 75d74ebdf9ca65aa4e2589ce8acf23aa2e7ff52d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 16:58:33 +0800 Subject: [PATCH 41/62] docs: describe steward automation projection --- docs/backend/desk/app/steward.md | 142 +++++++++++++++--- .../tasks.md | 2 +- packages/openclaw/README.md | 8 + 3 files changed, 130 insertions(+), 22 deletions(-) diff --git a/docs/backend/desk/app/steward.md b/docs/backend/desk/app/steward.md index c2eb05aaa2..a03bd61538 100644 --- a/docs/backend/desk/app/steward.md +++ b/docs/backend/desk/app/steward.md @@ -6,37 +6,49 @@ Ship-native umbrella agent: the durable, always-on ship-side half of an ephemera `%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` | +| 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. | +| 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. 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. +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 -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: +`%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 - 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 +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`): ``` @@ -78,9 +90,69 @@ While the gateway is not live, a DM from the configured `owner` triggers a canne `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 `text` | `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", + "text": "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. + +Reconciliation is serialized so an older snapshot cannot overtake a newer one. 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. Until a later operation succeeds, the ship retains its last successful projection. + +`gateway_stop` cancels retry delays, 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. 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 -Three inbound marks, each ownership-gated to admit exactly the right source. +Four inbound marks, each ownership-gated to admit exactly the right source. ### `%steward-action-1` (core config) — `src == our` @@ -127,14 +199,25 @@ Only the local gateway drives liveness, so this requires `src == our`. [%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 -All lens scries return the `%steward-lens-update-1` mark so the HTTP client reads them as JSON. +All scries are local only because `on-peek` requires `src.bowl == our.bowl`. 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. @@ -142,6 +225,23 @@ All lens scries return the `%steward-lens-update-1` mark so the HTTP client read - `/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. A foreign Gall source is rejected before the automation peek runs. `entry` is `[bot=ship id=@t run]`. The `%entry` update grows to JSON for Eyre, embedding the stored payload directly: @@ -151,10 +251,10 @@ All lens scries return the `%steward-lens-update-1` mark so the HTTP client read ## 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. +- `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` 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). +- `on-watch` and `on-peek` assert `=(src our)` — no cross-ship subscriptions or foreign scries. 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 diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 351c1288f4..b1dab3e458 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -89,7 +89,7 @@ ## 5. Documentation and Validation -- [ ] 5.1 Document the Steward automation state, migration, local +- [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. 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: From ebed000183bf72278525a6ad1eb3bb155e0bb325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 17:01:14 +0800 Subject: [PATCH 42/62] openspec: record final backend validation --- .../changes/mirror-openclaw-automations-to-steward/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index b1dab3e458..8684809fa3 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -93,7 +93,7 @@ harness `%project` action and atomic projection-commit semantics, best-effort OpenClaw flow, exclusions, and JSON scry. -- [ ] 5.2 Run the targeted Hoon tests, applicable backend suite, and +- [x] 5.2 Run the targeted Hoon tests, applicable backend suite, and desk compilation on the development ship. - [ ] 5.3 Run OpenClaw formatting, linting, type checking, unit tests, and relevant integration tests against the existing From c839f09f05cf6ee137f8c993f8f524b243778d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 17:21:43 +0800 Subject: [PATCH 43/62] openspec: record final openclaw validation --- .../mirror-openclaw-automations-to-steward/tasks.md | 2 +- packages/openclaw/src/fixtures/README.md | 12 ++---------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 8684809fa3..992707d760 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -95,7 +95,7 @@ scry. - [x] 5.2 Run the targeted Hoon tests, applicable backend suite, and desk compilation on the development ship. -- [ ] 5.3 Run OpenClaw formatting, linting, type checking, unit +- [x] 5.3 Run OpenClaw formatting, linting, type checking, unit tests, and relevant integration tests against the existing pinned runtime. - [ ] 5.4 Run strict OpenSpec validation and verify implementation diff --git a/packages/openclaw/src/fixtures/README.md b/packages/openclaw/src/fixtures/README.md index d38fbced53..2db65cee3f 100644 --- a/packages/openclaw/src/fixtures/README.md +++ b/packages/openclaw/src/fixtures/README.md @@ -1,13 +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. They came from session file -`c87e8f5e-1a0c-4866-b967-5c3f44311ca7.jsonl`, jobs -`0634ad7a-3ba1-4a65-b64a-db04658d8e64` (`at`) and -`f8a8741a-af0f-4cf1-8da9-43faf429cc7b` (`every`). No captured -cron-expression job was present. +`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. They came from session file `c87e8f5e-1a0c-4866-b967-5c3f44311ca7.jsonl`, jobs `0634ad7a-3ba1-4a65-b64a-db04658d8e64` (`at`) and `f8a8741a-af0f-4cf1-8da9-43faf429cc7b` (`every`). No captured cron-expression job was present. -Names, IDs, message text, and delivery recipients were replaced. Field -presence, schedule and timestamp values, and runtime state shapes were -retained. The fixture contains no tokens, secrets, or session keys. +Names, IDs, message text, and delivery recipients were replaced. Field presence, schedule and timestamp values, and runtime state shapes were retained. The fixture contains no tokens, secrets, or session keys. From db07d557785629b6a70a51e8d06e6378e7a8f697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Mon, 10 Aug 2026 17:25:43 +0800 Subject: [PATCH 44/62] openspec: complete automation projection change --- .../changes/mirror-openclaw-automations-to-steward/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 992707d760..055ad447e7 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -98,5 +98,5 @@ - [x] 5.3 Run OpenClaw formatting, linting, type checking, unit tests, and relevant integration tests against the existing pinned runtime. -- [ ] 5.4 Run strict OpenSpec validation and verify implementation +- [x] 5.4 Run strict OpenSpec validation and verify implementation coverage for every capability scenario. From 973333c16aef50fb1c370825f7da6602b5721cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Tue, 11 Aug 2026 09:47:18 +0800 Subject: [PATCH 45/62] steward: capitalize automation comments --- desk/app/steward.hoon | 10 +++---- desk/lib/steward/automation-json.hoon | 2 +- desk/lib/steward/automation.hoon | 10 +++---- desk/mar/steward/automation/action-1.hoon | 2 +- desk/mar/steward/automation/task-map-1.hoon | 2 +- desk/sur/steward/automation.hoon | 26 +++++++++---------- desk/tests/app/steward.hoon | 2 +- desk/tests/lib/steward-automation-json.hoon | 2 +- desk/tests/lib/steward-automation.hoon | 2 +- .../tasks.md | 2 ++ 10 files changed, 31 insertions(+), 29 deletions(-) diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index 65088bda9d..4ae4caae35 100644 --- a/desk/app/steward.hoon +++ b/desk/app/steward.hoon @@ -6,7 +6,7 @@ :: 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 +:: 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). :: @@ -15,8 +15,8 @@ /+ default-agent, verb, dbug |% +$ card card:agent:gall -:: Versioned persisted state. state-0 is released and remains decodable for -:: migration. Fresh installs and migrated agents use state-1. +:: Versioned persisted state. The released state-0 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 @@ -147,7 +147,7 @@ %steward-gateway-action-1 (ga-poke-action:ga-core !<(action:v1:sg vase)) :: - :: automation snapshots. Authorization is enforced in au-poke-action. + :: Automation snapshots. Authorization is enforced in au-poke-action. :: %steward-automation-action-1 (au-poke-action:au-core !<(action:v1:sa vase)) @@ -652,7 +652,7 @@ (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: Automation projection module. :: ++ au-core |% diff --git a/desk/lib/steward/automation-json.hoon b/desk/lib/steward/automation-json.hoon index 3806f2fcc4..d412f01627 100644 --- a/desk/lib/steward/automation-json.hoon +++ b/desk/lib/steward/automation-json.hoon @@ -1,4 +1,4 @@ -:: JSON conversion helpers for steward automation marks. +:: 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 diff --git a/desk/lib/steward/automation.hoon b/desk/lib/steward/automation.hoon index fd42ccf4f6..569aebaa96 100644 --- a/desk/lib/steward/automation.hoon +++ b/desk/lib/steward/automation.hoon @@ -1,29 +1,29 @@ -:: Time conversions for the steward automation protocol. +:: Time conversions for the %steward automation protocol. :: :: OpenClaw represents absolute dates and durations as integer milliseconds. :: These wrappers use the standard conversions supplied by zuse. :: =* z ..zuse |% -:: +milliseconds-to-duration: integer milliseconds to an Urbit duration. +:: Convert integer milliseconds to an Urbit duration. :: ++ milliseconds-to-duration |= milliseconds=@ud ^- @dr `@dr`(div (mul milliseconds ~s1) 1.000) -:: +duration-to-milliseconds: an Urbit duration to integer milliseconds. +:: Convert an Urbit duration to integer milliseconds. :: ++ duration-to-milliseconds |= duration=@dr ^- @ud (msec:milly:z duration) -:: +unix-milliseconds-to-date: Unix epoch milliseconds to an Urbit date. +:: Convert Unix epoch milliseconds to an Urbit date. :: ++ unix-milliseconds-to-date |= milliseconds=@ud ^- @da (from-unix-ms:chrono:userlib:z milliseconds) -:: +date-to-unix-milliseconds: an Urbit date to Unix epoch milliseconds. +:: Convert an Urbit date to Unix epoch milliseconds. :: ++ date-to-unix-milliseconds |= date=@da diff --git a/desk/mar/steward/automation/action-1.hoon b/desk/mar/steward/automation/action-1.hoon index ea357c1bf4..740057d893 100644 --- a/desk/mar/steward/automation/action-1.hoon +++ b/desk/mar/steward/automation/action-1.hoon @@ -1,4 +1,4 @@ -:: %steward-automation-action-1: complete task projection action +:: %steward-automation-action-1: Complete task projection action. :: /- a=steward-automation /+ aj=steward-automation-json diff --git a/desk/mar/steward/automation/task-map-1.hoon b/desk/mar/steward/automation/task-map-1.hoon index e046e95d6c..dacf5214e7 100644 --- a/desk/mar/steward/automation/task-map-1.hoon +++ b/desk/mar/steward/automation/task-map-1.hoon @@ -1,4 +1,4 @@ -:: %steward-automation-task-map-1: ID-keyed automation scry result +:: %steward-automation-task-map-1: ID-keyed automation scry result. :: /- a=steward-automation /+ aj=steward-automation-json diff --git a/desk/sur/steward/automation.hoon b/desk/sur/steward/automation.hoon index 2ca4321845..ad769fa8e5 100644 --- a/desk/sur/steward/automation.hoon +++ b/desk/sur/steward/automation.hoon @@ -1,24 +1,24 @@ -:: steward automation module: mirrored OpenClaw task definitions +:: Steward automation module: Mirrored OpenClaw task definitions. :: |% -:: $cron-schedule: the supported OpenClaw schedule variants. OpenClaw uses -:: integer milliseconds at the boundary; the Hoon representation stores dates -:: and durations in their native atom types. +:: $cron-schedule: Supported OpenClaw schedule variants. OpenClaw uses integer +:: milliseconds at the boundary; 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)] == -:: $cron-payload: the definition fields of an OpenClaw task payload. +:: $cron-payload: Definition fields of an OpenClaw task payload. :: +$ cron-payload $: kind=(unit @t) text=(unit @t) == -:: $task: the supported definition-only subset of -:: PluginHookGatewayCronJob. The OpenClaw ID is stored separately as the map -:: key. Runtime job state and execution history are not represented. +:: $task: Supported definition-only subset of PluginHookGatewayCronJob. The +:: OpenClaw ID is stored separately as the map key. Runtime job state and +:: execution history are not represented. :: +$ task $: agent-id=(unit @t) @@ -32,25 +32,25 @@ created-at=(unit @da) updated-at=(unit @da) == -:: $identified-task: an inbound task paired with its OpenClaw ID. +:: $identified-task: Inbound task paired with its OpenClaw ID. :: +$ identified-task $: id=@t =task == -:: $state: the latest complete task projection, keyed by OpenClaw task ID. +:: $state: Latest complete task projection, keyed by OpenClaw task ID. :: +$ state $: tasks=(map @t task) == -:: $action: inbound automation actions from the local harness. +:: $action: Inbound automation actions from the local harness. :: -:: %project: atomically replace the complete task projection. +:: %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: ID-keyed task map returned by the automation scry. :: +$ task-map (map @t task) ++ v1 . diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 50566466cb..89188b1e00 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -1,4 +1,4 @@ -:: tests for %steward agent modules +:: Tests for %steward agent modules. :: /- s=steward, a=activity, av=activity-ver /- l=steward-lens, g=steward-gateway, au=steward-automation diff --git a/desk/tests/lib/steward-automation-json.hoon b/desk/tests/lib/steward-automation-json.hoon index 1e93a93845..777e5baf4c 100644 --- a/desk/tests/lib/steward-automation-json.hoon +++ b/desk/tests/lib/steward-automation-json.hoon @@ -1,4 +1,4 @@ -:: steward automation production JSON codec tests +:: Steward automation production JSON codec tests. :: /- a=steward-automation /+ *test, aj=steward-automation-json, au=steward-automation diff --git a/desk/tests/lib/steward-automation.hoon b/desk/tests/lib/steward-automation.hoon index 83c01d1fc4..493414d465 100644 --- a/desk/tests/lib/steward-automation.hoon +++ b/desk/tests/lib/steward-automation.hoon @@ -1,4 +1,4 @@ -:: steward automation time conversion tests +:: Steward automation time conversion tests. :: /+ *test, au=steward-automation |% diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 055ad447e7..5581c57429 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -100,3 +100,5 @@ pinned runtime. - [x] 5.4 Run strict OpenSpec validation and verify implementation coverage for every capability scenario. +- [x] 5.5 Align comments in all automation Hoon code with existing + agent capitalization style. From 75f340dfd6ea15f45f6d2dc4fa6dc5f86ecfdcb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Tue, 11 Aug 2026 09:50:56 +0800 Subject: [PATCH 46/62] Revert "steward: capitalize automation comments" This reverts commit 973333c16aef50fb1c370825f7da6602b5721cc1. --- desk/app/steward.hoon | 10 +++---- desk/lib/steward/automation-json.hoon | 2 +- desk/lib/steward/automation.hoon | 10 +++---- desk/mar/steward/automation/action-1.hoon | 2 +- desk/mar/steward/automation/task-map-1.hoon | 2 +- desk/sur/steward/automation.hoon | 26 +++++++++---------- desk/tests/app/steward.hoon | 2 +- desk/tests/lib/steward-automation-json.hoon | 2 +- desk/tests/lib/steward-automation.hoon | 2 +- .../tasks.md | 2 -- 10 files changed, 29 insertions(+), 31 deletions(-) diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index 4ae4caae35..65088bda9d 100644 --- a/desk/app/steward.hoon +++ b/desk/app/steward.hoon @@ -6,7 +6,7 @@ :: 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 +:: 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). :: @@ -15,8 +15,8 @@ /+ default-agent, verb, dbug |% +$ card card:agent:gall -:: Versioned persisted state. The released state-0 remains decodable for -:: migration; fresh installs and migrated agents use state-1. +:: 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 @@ -147,7 +147,7 @@ %steward-gateway-action-1 (ga-poke-action:ga-core !<(action:v1:sg vase)) :: - :: Automation snapshots. Authorization is enforced in au-poke-action. + :: automation snapshots. Authorization is enforced in au-poke-action. :: %steward-automation-action-1 (au-poke-action:au-core !<(action:v1:sa vase)) @@ -652,7 +652,7 @@ (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: automation projection module :: ++ au-core |% diff --git a/desk/lib/steward/automation-json.hoon b/desk/lib/steward/automation-json.hoon index d412f01627..3806f2fcc4 100644 --- a/desk/lib/steward/automation-json.hoon +++ b/desk/lib/steward/automation-json.hoon @@ -1,4 +1,4 @@ -:: JSON conversion helpers for %steward automation marks. +:: 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 diff --git a/desk/lib/steward/automation.hoon b/desk/lib/steward/automation.hoon index 569aebaa96..fd42ccf4f6 100644 --- a/desk/lib/steward/automation.hoon +++ b/desk/lib/steward/automation.hoon @@ -1,29 +1,29 @@ -:: Time conversions for the %steward automation protocol. +:: Time conversions for the steward automation protocol. :: :: OpenClaw represents absolute dates and durations as integer milliseconds. :: These wrappers use the standard conversions supplied by zuse. :: =* z ..zuse |% -:: Convert integer milliseconds to an Urbit duration. +:: +milliseconds-to-duration: integer milliseconds to an Urbit duration. :: ++ milliseconds-to-duration |= milliseconds=@ud ^- @dr `@dr`(div (mul milliseconds ~s1) 1.000) -:: Convert an Urbit duration to integer milliseconds. +:: +duration-to-milliseconds: an Urbit duration to integer milliseconds. :: ++ duration-to-milliseconds |= duration=@dr ^- @ud (msec:milly:z duration) -:: Convert Unix epoch milliseconds to an Urbit date. +:: +unix-milliseconds-to-date: Unix epoch milliseconds to an Urbit date. :: ++ unix-milliseconds-to-date |= milliseconds=@ud ^- @da (from-unix-ms:chrono:userlib:z milliseconds) -:: Convert an Urbit date to Unix epoch milliseconds. +:: +date-to-unix-milliseconds: an Urbit date to Unix epoch milliseconds. :: ++ date-to-unix-milliseconds |= date=@da diff --git a/desk/mar/steward/automation/action-1.hoon b/desk/mar/steward/automation/action-1.hoon index 740057d893..ea357c1bf4 100644 --- a/desk/mar/steward/automation/action-1.hoon +++ b/desk/mar/steward/automation/action-1.hoon @@ -1,4 +1,4 @@ -:: %steward-automation-action-1: Complete task projection action. +:: %steward-automation-action-1: complete task projection action :: /- a=steward-automation /+ aj=steward-automation-json diff --git a/desk/mar/steward/automation/task-map-1.hoon b/desk/mar/steward/automation/task-map-1.hoon index dacf5214e7..e046e95d6c 100644 --- a/desk/mar/steward/automation/task-map-1.hoon +++ b/desk/mar/steward/automation/task-map-1.hoon @@ -1,4 +1,4 @@ -:: %steward-automation-task-map-1: ID-keyed automation scry result. +:: %steward-automation-task-map-1: ID-keyed automation scry result :: /- a=steward-automation /+ aj=steward-automation-json diff --git a/desk/sur/steward/automation.hoon b/desk/sur/steward/automation.hoon index ad769fa8e5..2ca4321845 100644 --- a/desk/sur/steward/automation.hoon +++ b/desk/sur/steward/automation.hoon @@ -1,24 +1,24 @@ -:: Steward automation module: Mirrored OpenClaw task definitions. +:: steward automation module: mirrored OpenClaw task definitions :: |% -:: $cron-schedule: Supported OpenClaw schedule variants. OpenClaw uses integer -:: milliseconds at the boundary; the Hoon representation stores dates and -:: durations in their native atom types. +:: $cron-schedule: the supported OpenClaw schedule variants. OpenClaw uses +:: integer milliseconds at the boundary; 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)] == -:: $cron-payload: Definition fields of an OpenClaw task payload. +:: $cron-payload: the definition fields of an OpenClaw task payload. :: +$ cron-payload $: kind=(unit @t) text=(unit @t) == -:: $task: Supported definition-only subset of PluginHookGatewayCronJob. The -:: OpenClaw ID is stored separately as the map key. Runtime job state and -:: execution history are not represented. +:: $task: the supported definition-only subset of +:: PluginHookGatewayCronJob. The OpenClaw ID is stored separately as the map +:: key. Runtime job state and execution history are not represented. :: +$ task $: agent-id=(unit @t) @@ -32,25 +32,25 @@ created-at=(unit @da) updated-at=(unit @da) == -:: $identified-task: Inbound task paired with its OpenClaw ID. +:: $identified-task: an inbound task paired with its OpenClaw ID. :: +$ identified-task $: id=@t =task == -:: $state: Latest complete task projection, keyed by OpenClaw task ID. +:: $state: the latest complete task projection, keyed by OpenClaw task ID. :: +$ state $: tasks=(map @t task) == -:: $action: Inbound automation actions from the local harness. +:: $action: inbound automation actions from the local harness. :: -:: %project: Atomically replace the complete task projection. +:: %project: atomically replace the complete task projection. :: +$ action $% [%project tasks=(list identified-task)] == -:: $task-map: ID-keyed task map returned by the automation scry. +:: $task-map: the ID-keyed task map returned by the automation scry. :: +$ task-map (map @t task) ++ v1 . diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 89188b1e00..50566466cb 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -1,4 +1,4 @@ -:: Tests for %steward agent modules. +:: tests for %steward agent modules :: /- s=steward, a=activity, av=activity-ver /- l=steward-lens, g=steward-gateway, au=steward-automation diff --git a/desk/tests/lib/steward-automation-json.hoon b/desk/tests/lib/steward-automation-json.hoon index 777e5baf4c..1e93a93845 100644 --- a/desk/tests/lib/steward-automation-json.hoon +++ b/desk/tests/lib/steward-automation-json.hoon @@ -1,4 +1,4 @@ -:: Steward automation production JSON codec tests. +:: steward automation production JSON codec tests :: /- a=steward-automation /+ *test, aj=steward-automation-json, au=steward-automation diff --git a/desk/tests/lib/steward-automation.hoon b/desk/tests/lib/steward-automation.hoon index 493414d465..83c01d1fc4 100644 --- a/desk/tests/lib/steward-automation.hoon +++ b/desk/tests/lib/steward-automation.hoon @@ -1,4 +1,4 @@ -:: Steward automation time conversion tests. +:: steward automation time conversion tests :: /+ *test, au=steward-automation |% diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 5581c57429..055ad447e7 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -100,5 +100,3 @@ pinned runtime. - [x] 5.4 Run strict OpenSpec validation and verify implementation coverage for every capability scenario. -- [x] 5.5 Align comments in all automation Hoon code with existing - agent capitalization style. From 4110136773bf8ef882fe1c8418758fe23f4f7ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Tue, 11 Aug 2026 09:59:58 +0800 Subject: [PATCH 47/62] steward: lowercase automation comments --- desk/app/steward.hoon | 12 +++++----- desk/lib/steward/automation-json.hoon | 6 ++--- desk/lib/steward/automation.hoon | 15 ++++++------ desk/mar/steward/automation/task-map-1.hoon | 2 +- desk/sur/steward/automation.hoon | 24 +++++++++---------- desk/tests/app/steward.hoon | 8 +++---- desk/tests/lib/steward-automation-json.hoon | 10 ++++---- .../tasks.md | 2 ++ 8 files changed, 41 insertions(+), 38 deletions(-) diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index 65088bda9d..e2b124e150 100644 --- a/desk/app/steward.hoon +++ b/desk/app/steward.hoon @@ -15,8 +15,8 @@ /+ default-agent, verb, dbug |% +$ card card:agent:gall -:: Versioned persisted state. state-0 is released and remains decodable for -:: migration. Fresh installs and migrated agents use state-1. +:: 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 @@ -68,7 +68,7 @@ =? old ?=(%0 -.old) (state-0-to-1 old) ?> ?=(%1 -.old) `this(state old) - :: Preserve every released field and initialize the new module empty. + :: preserve every released field and initialize the new module empty :: ++ state-0-to-1 |= old=state-0 @@ -147,7 +147,7 @@ %steward-gateway-action-1 (ga-poke-action:ga-core !<(action:v1:sg vase)) :: - :: automation snapshots. Authorization is enforced in au-poke-action. + :: automation snapshots. authorization is enforced in au-poke-action :: %steward-automation-action-1 (au-poke-action:au-core !<(action:v1:sa vase)) @@ -672,8 +672,8 @@ [%v1 %tasks ~] ``steward-automation-task-map-1+!>(tasks.automation.state) == - :: Build the complete replacement before mutating state. A duplicate ID - :: crashes here, leaving the previous projection untouched. + :: build the complete replacement before mutating state. a duplicate ID + :: crashes here, leaving the previous projection untouched :: ++ au-build-task-map |= entries=(list identified-task:v1:sa) diff --git a/desk/lib/steward/automation-json.hoon b/desk/lib/steward/automation-json.hoon index 3806f2fcc4..d112c6e6f8 100644 --- a/desk/lib/steward/automation-json.hoon +++ b/desk/lib/steward/automation-json.hoon @@ -1,6 +1,6 @@ -:: JSON conversion helpers for steward automation marks. +:: json conversion helpers for steward automation marks :: -:: Pinned OpenClaw exposes the %at schedule's `at` as an ISO string. The +:: 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. @@ -21,7 +21,7 @@ ?~ value ~ (some (wit u.value)) ++ schedule-from-json - :: OpenClaw schedules use a `kind` field, not a tagged JSON object. + :: schedules from OpenClaw use a `kind` field, not a tagged JSON object |= jon=json ^- cron-schedule:v1:a ?> ?=([%o *] jon) diff --git a/desk/lib/steward/automation.hoon b/desk/lib/steward/automation.hoon index fd42ccf4f6..9b8d361b70 100644 --- a/desk/lib/steward/automation.hoon +++ b/desk/lib/steward/automation.hoon @@ -1,29 +1,30 @@ -:: Time conversions for the steward automation protocol. +:: time conversions for the steward automation protocol :: -:: OpenClaw represents absolute dates and durations as integer milliseconds. -:: These wrappers use the standard conversions supplied by zuse. +:: task definitions from OpenClaw represent absolute dates and durations as +:: integer milliseconds. these wrappers use the standard conversions supplied +:: by zuse. :: =* z ..zuse |% -:: +milliseconds-to-duration: integer milliseconds to an Urbit duration. +:: +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: an Urbit duration to integer milliseconds. +:: +duration-to-milliseconds: convert an Urbit duration to integer milliseconds :: ++ duration-to-milliseconds |= duration=@dr ^- @ud (msec:milly:z duration) -:: +unix-milliseconds-to-date: Unix epoch milliseconds to an Urbit date. +:: +unix-milliseconds-to-date: convert Unix epoch milliseconds to an Urbit date :: ++ unix-milliseconds-to-date |= milliseconds=@ud ^- @da (from-unix-ms:chrono:userlib:z milliseconds) -:: +date-to-unix-milliseconds: an Urbit date to Unix epoch milliseconds. +:: +date-to-unix-milliseconds: convert an Urbit date to Unix epoch milliseconds :: ++ date-to-unix-milliseconds |= date=@da diff --git a/desk/mar/steward/automation/task-map-1.hoon b/desk/mar/steward/automation/task-map-1.hoon index e046e95d6c..266b74a291 100644 --- a/desk/mar/steward/automation/task-map-1.hoon +++ b/desk/mar/steward/automation/task-map-1.hoon @@ -1,4 +1,4 @@ -:: %steward-automation-task-map-1: ID-keyed automation scry result +:: %steward-automation-task-map-1: an ID-keyed automation scry result :: /- a=steward-automation /+ aj=steward-automation-json diff --git a/desk/sur/steward/automation.hoon b/desk/sur/steward/automation.hoon index 2ca4321845..9c0b411154 100644 --- a/desk/sur/steward/automation.hoon +++ b/desk/sur/steward/automation.hoon @@ -1,24 +1,24 @@ :: steward automation module: mirrored OpenClaw task definitions :: |% -:: $cron-schedule: the supported OpenClaw schedule variants. OpenClaw uses -:: integer milliseconds at the boundary; the Hoon representation stores dates -:: and durations in their native atom types. +:: $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)] == -:: $cron-payload: the definition fields of an OpenClaw task payload. +:: $cron-payload: the definition fields of an OpenClaw task payload :: +$ cron-payload $: kind=(unit @t) text=(unit @t) == -:: $task: the supported definition-only subset of -:: PluginHookGatewayCronJob. The OpenClaw ID is stored separately as the map -:: key. Runtime job state and execution history are not represented. +:: $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) @@ -32,25 +32,25 @@ created-at=(unit @da) updated-at=(unit @da) == -:: $identified-task: an inbound task paired with its OpenClaw ID. +:: $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: the latest complete task projection, keyed by OpenClaw task ID :: +$ state $: tasks=(map @t task) == -:: $action: inbound automation actions from the local harness. +:: $action: inbound automation actions from the local harness :: -:: %project: atomically replace the complete task projection. +:: %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: the ID-keyed task map returned by the automation scry :: +$ task-map (map @t task) ++ v1 . diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 50566466cb..669ba2dfdf 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -6,7 +6,7 @@ /= agent /app/steward |% ++ dap %steward -:: Current state and the released state shape accepted by +on-load. +:: current state and the released state shape accepted by +on-load :: +$ state-1 $: %1 @@ -176,7 +176,7 @@ (ex-equal !>(tasks.automation.current) !>(*(map @t task:v1:au))) :: :: ========================================================== -:: RELEASED STATE MIGRATION TESTS +:: released state migration tests :: ========================================================== :: ++ test-migration-preserves-populated-released-state @@ -223,7 +223,7 @@ (ex-equal !>(after) !>(before)) :: :: ========================================================== -:: AUTOMATION MODULE TESTS +:: automation module tests :: ========================================================== :: ++ test-automation-project-populates-id-keyed-map @@ -799,7 +799,7 @@ %- (do-as ~zod) (do-poke %steward-lens-action-1 !>(`action:v1:l`[%retry ~dev 'lens-r'])) :: -:: Fresh initialization uses current state, seeds lens, and starts empty. +:: fresh initialization uses current state, seeds lens, and starts empty :: ++ test-migration-fresh-initialization %- eval-mare diff --git a/desk/tests/lib/steward-automation-json.hoon b/desk/tests/lib/steward-automation-json.hoon index 1e93a93845..3ddc9cd6c8 100644 --- a/desk/tests/lib/steward-automation-json.hoon +++ b/desk/tests/lib/steward-automation-json.hoon @@ -90,9 +90,9 @@ %- ~(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. +:: 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) @@ -103,8 +103,8 @@ !>((action-to-json:aj actual)) == :: -:: No cron-expression job was present in the captured runtime history. Keep -:: this synthetic case focused on the third supported schedule codec. +:: no cron-expression job was present in the captured runtime history. keep +:: this synthetic case focused on the third supported schedule codec :: ++ test-focused-cron-schedule-codec =/ body=@t diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 055ad447e7..5c69e5d81a 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -100,3 +100,5 @@ 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. From d8b38c65ca09b8e4f2ed7f8491d14984f986cfc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Tue, 11 Aug 2026 10:35:43 +0800 Subject: [PATCH 48/62] steward: group automation JSON codecs --- desk/lib/steward/automation-json.hoon | 327 +++++++++--------- desk/mar/steward/automation/action-1.hoon | 4 +- desk/mar/steward/automation/task-map-1.hoon | 4 +- desk/tests/app/steward.hoon | 10 +- desk/tests/lib/steward-automation-json.hoon | 22 +- .../tasks.md | 2 + 6 files changed, 183 insertions(+), 186 deletions(-) diff --git a/desk/lib/steward/automation-json.hoon b/desk/lib/steward/automation-json.hoon index d112c6e6f8..79cba52147 100644 --- a/desk/lib/steward/automation-json.hoon +++ b/desk/lib/steward/automation-json.hoon @@ -8,182 +8,177 @@ /- a=steward-automation /+ au=steward-automation |% -++ duration-from-json +++ dejs =, dejs:format - (cu milliseconds-to-duration:au ni) -++ date-from-json - =, dejs:format - (cu unix-milliseconds-to-date:au ni) -++ optional-from-json - |* [key=@t wit=$-(json *) jon=json] - ?> ?=([%o *] jon) - =/ value (~(get by p.jon) key) - ?~ value ~ - (some (wit u.value)) -++ schedule-from-json - :: schedules from OpenClaw use a `kind` field, not a tagged JSON object - |= jon=json - ^- cron-schedule:v1:a - ?> ?=([%o *] jon) - =, dejs:format - =/ kind (so (~(got by p.jon) 'kind')) - ?: =('cron' kind) - :* %cron - (optional-from-json 'expr' so jon) - (optional-from-json 'tz' so jon) - (optional-from-json 'staggerMs' duration-from-json jon) + |% + ++ 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 + ^- cron-payload:v1:a + :* (optional 'kind' so jon) + (optional 'text' so jon) == - ?: =('at' kind) - [%at (optional-from-json 'at' date-from-json jon)] - ?: =('every' kind) - :* %every - (optional-from-json 'everyMs' duration-from-json jon) - (optional-from-json 'anchorMs' date-from-json 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) == - ~|(bad-schedule-kind+kind !!) -++ payload-from-json - |= jon=json - ^- cron-payload:v1:a - =, dejs:format - :* (optional-from-json 'kind' so jon) - (optional-from-json 'text' so jon) - == -++ task-from-json - |= jon=json - ^- task:v1:a - =, dejs:format - :* (optional-from-json 'agentId' so jon) - (optional-from-json 'name' so jon) - (optional-from-json 'description' so jon) - (optional-from-json 'enabled' bo jon) - (optional-from-json 'schedule' schedule-from-json jon) - (optional-from-json 'sessionTarget' so jon) - (optional-from-json 'wakeMode' so jon) - (optional-from-json 'payload' payload-from-json jon) - (optional-from-json 'createdAtMs' date-from-json jon) - (optional-from-json 'updatedAtMs' date-from-json jon) - == -++ identified-task-from-json - |= jon=json - ^- identified-task:v1:a - ?> ?=([%o *] jon) - =/ id-json=json (~(got by p.jon) 'id') - =, dejs:format - [(so id-json) (task-from-json jon)] -++ project-from-json - |= jon=json - ^- (list identified-task:v1:a) - =, dejs:format - =/ tasks=(list identified-task:v1:a) - ((ot tasks+(ar identified-task-from-json) ~) 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-from-json - |= jon=json - ^- action:v1:a - =, dejs:format - %. jon - (of ~[[%project project-from-json]]) -++ schedule-to-json - |= schedule=cron-schedule:v1:a - ^- json + ++ 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 - %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] + |% + ++ 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=cron-payload:v1:a + ^- json + =/ fields=(list [@t json]) ~ + =. fields ?~(kind.payload fields [['kind' s+u.kind.payload] fields]) + =. fields ?~(text.payload fields [['text' s+u.text.payload] fields]) (pairs fields) - :: - %at - =/ fields=(list [@t json]) ~[['kind' s+'at']] + ++ 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 - ?~ at.schedule + ?~ session-target.task fields - [['at' (numb (date-to-unix-milliseconds:au u.at.schedule))] fields] - (pairs fields) - :: - %every - =/ fields=(list [@t json]) ~[['kind' s+'every']] + [['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 - ?~ every.schedule + ?~ created-at.task fields - [['everyMs' (numb (duration-to-milliseconds:au u.every.schedule))] fields] + [['createdAtMs' (numb (date-to-unix-milliseconds:au u.created-at.task))] fields] =. fields - ?~ anchor.schedule + ?~ updated-at.task fields - [['anchorMs' (numb (date-to-unix-milliseconds:au u.anchor.schedule))] fields] + [['updatedAtMs' (numb (date-to-unix-milliseconds:au u.updated-at.task))] fields] (pairs fields) - == -++ payload-to-json - |= payload=cron-payload:v1:a - ^- json - =, enjs:format - =/ fields=(list [@t json]) ~ - =. fields ?~(kind.payload fields [['kind' s+u.kind.payload] fields]) - =. fields ?~(text.payload fields [['text' s+u.text.payload] fields]) - (pairs fields) -++ task-to-json - |= =task:v1:a - ^- json - =, enjs:format - =/ 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-to-json 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-to-json 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-to-json - |= entry=identified-task:v1:a - ^- json - =/ jon=json (task-to-json task.entry) - ?> ?=([%o *] jon) - [%o (~(put by p.jon) 'id' [%s id.entry])] -++ action-to-json - |= =action:v1:a - ^- json - =, enjs:format - ?- -.action - %project - (frond 'project' (frond 'tasks' a+(turn tasks.action identified-task-to-json))) - == -++ task-map-from-json - |= jon=json - ^- task-map:v1:a - =, dejs:format - ((ot tasks+(om task-from-json) ~) jon) -++ task-map-to-json - |= tasks=task-map:v1:a - ^- json - =, enjs:format - (frond 'tasks' [%o (~(run by tasks) task-to-json)]) + ++ 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/mar/steward/automation/action-1.hoon b/desk/mar/steward/automation/action-1.hoon index ea357c1bf4..00b489ec90 100644 --- a/desk/mar/steward/automation/action-1.hoon +++ b/desk/mar/steward/automation/action-1.hoon @@ -7,11 +7,11 @@ ++ grow |% ++ noun action - ++ json (action-to-json:aj action) + ++ json (action:enjs:aj action) -- ++ grab |% ++ noun action:v1:a - ++ json action-from-json:aj + ++ json action:dejs:aj -- -- diff --git a/desk/mar/steward/automation/task-map-1.hoon b/desk/mar/steward/automation/task-map-1.hoon index 266b74a291..709dd4119a 100644 --- a/desk/mar/steward/automation/task-map-1.hoon +++ b/desk/mar/steward/automation/task-map-1.hoon @@ -7,11 +7,11 @@ ++ grow |% ++ noun tasks - ++ json (task-map-to-json:aj tasks) + ++ json (task-map:enjs:aj tasks) -- ++ grab |% ++ noun task-map:v1:a - ++ json task-map-from-json:aj + ++ json task-map:dejs:aj -- -- diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 669ba2dfdf..d4f0baa397 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -55,7 +55,7 @@ ++ project-automation-json |= body=@t =/ action=action:v1:au - (action-from-json:aj (parse-json body)) + (action:dejs:aj (parse-json body)) (project-automation tasks.action) ++ trace-project-json ^- @t @@ -325,7 +325,7 @@ =/ m (mare ,~) ^- form:m =/ action=action:v1:au - (action-from-json:aj (parse-json trace-project-json)) + (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) @@ -337,7 +337,7 @@ (~(gas by *(map @t task:v1:au)) projected) ;< ~ bind:m (ex-equal !>(actual) !>(expected)) %+ ex-equal - !>((task-map-to-json:aj actual)) + !>((task-map:enjs:aj actual)) !>((parse-json trace-task-map-json)) :: ++ test-automation-project-persists-through-save-load @@ -345,7 +345,7 @@ =/ m (mare ,~) ^- form:m =/ action=action:v1:au - (action-from-json:aj (parse-json trace-project-json)) + (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 @@ -365,7 +365,7 @@ ;< ~ 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-to-json:aj actual)) !>((parse-json expected))) + (ex-equal !>((task-map:enjs:aj actual)) !>((parse-json expected))) :: ++ test-automation-json-scry-reconciles-and-persists %- eval-mare diff --git a/desk/tests/lib/steward-automation-json.hoon b/desk/tests/lib/steward-automation-json.hoon index 3ddc9cd6c8..e41759093e 100644 --- a/desk/tests/lib/steward-automation-json.hoon +++ b/desk/tests/lib/steward-automation-json.hoon @@ -10,7 +10,7 @@ ++ parse-action |= body=@t ^- action:v1:a - (action-from-json:aj (parse-json body)) + (action:dejs:aj (parse-json body)) ++ empty-task ^- task:v1:a :* ~ @@ -100,7 +100,7 @@ (expect-eq !>(trace-action) !>(actual)) %+ expect-eq !>((parse-json trace-project-json)) - !>((action-to-json:aj actual)) + !>((action:enjs:aj actual)) == :: :: no cron-expression job was present in the captured runtime history. keep @@ -113,14 +113,14 @@ =/ actual=action:v1:a (parse-action body) ;: weld (expect-eq !>(expected) !>(actual)) - (expect-eq !>((parse-json body)) !>((action-to-json:aj 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-to-json:aj actual))) + (expect-eq !>((parse-json body)) !>((action:enjs:aj actual))) == ++ test-absent-optionals-roundtrip =/ expected=action:v1:a [%project ~[['empty' empty-task]]] @@ -130,7 +130,7 @@ (expect-eq !>(expected) !>(actual)) %+ expect-eq !>((parse-json '{"project":{"tasks":[{"id":"empty"}]}}')) - !>((action-to-json:aj actual)) + !>((action:enjs:aj actual)) == ++ test-invalid-json-rejected %- expect-fail @@ -144,26 +144,26 @@ |. %- parse-action '{"project":{"tasks":[{"id":"bad","schedule":{"kind":"once"}}]}}' ++ test-trace-task-map-grows-ids-as-keys-only - =/ actual=json (task-map-to-json:aj trace-task-map) + =/ 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-from-json:aj 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-to-json:aj tasks) + =/ actual=json (task-map:enjs:aj tasks) ;: weld (expect-eq !>(expected) !>(actual)) - (expect-eq !>(tasks) !>((task-map-from-json:aj 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-to-json:aj tasks))) - (expect-eq !>(tasks) !>((task-map-from-json:aj expected))) + (expect-eq !>(expected) !>((task-map:enjs:aj tasks))) + (expect-eq !>(tasks) !>((task-map:dejs:aj expected))) == -- diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 5c69e5d81a..04a974754f 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -102,3 +102,5 @@ 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. From 861b9f815c140d00774f98bcd54324b5e90a8163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Tue, 11 Aug 2026 10:42:22 +0800 Subject: [PATCH 49/62] steward: remove dotket source checks --- desk/app/steward.hoon | 1 - desk/tests/app/steward.hoon | 31 ------------------- docs/backend/desk/app/steward.md | 6 ++-- .../design.md | 7 +++-- .../proposal.md | 15 ++++----- .../steward-automation-projection/spec.md | 27 +++++++--------- .../tasks.md | 2 ++ 7 files changed, 29 insertions(+), 60 deletions(-) diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index e2b124e150..0690dea78e 100644 --- a/desk/app/steward.hoon +++ b/desk/app/steward.hoon @@ -95,7 +95,6 @@ ++ on-peek |= =path ^- (unit (unit cage)) - ?> =(src our):bowl (peek:cor path) ++ on-agent |= [=wire =sign:agent:gall] diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index d4f0baa397..edea5ca922 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -384,19 +384,6 @@ ;< * bind:m (do-load agent ~) (assert-automation-task-map-json reconcile-current-task-map-json) :: -++ test-automation-tasks-scry-rejects-foreign - %- eval-mare - =/ m (mare ,~) - ^- form:m - ;< ~ bind:m setup - ;< ~ bind:m (set-src ~zod) - |= s=state - =/ result - (mule |.((~(on-peek agent.s bowl.s) /x/v1/automation/tasks))) - ?: ?=(%& -.result) - |+~['expected foreign /x/v1/automation/tasks peek to crash'] - &+[~ s] -:: :: ========================================================== :: LENS MODULE TESTS :: ========================================================== @@ -827,24 +814,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 :: ========================================================== diff --git a/docs/backend/desk/app/steward.md b/docs/backend/desk/app/steward.md index a03bd61538..23db64fbb7 100644 --- a/docs/backend/desk/app/steward.md +++ b/docs/backend/desk/app/steward.md @@ -217,7 +217,7 @@ Each `identified-task` is `[id=@t task]` on the noun side. The mark's JSON form ## scry surface -All scries are local only because `on-peek` requires `src.bowl == our.bowl`. Lens scries return the `%steward-lens-update-1` mark so the HTTP client reads them as JSON. +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. @@ -241,7 +241,7 @@ The automation task-map mark grows to a JSON object whose property names are the } ``` -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. A foreign Gall source is rejected before the automation peek runs. +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: @@ -254,7 +254,7 @@ With no stored tasks the exact JSON shape is `{ "tasks": {} }`. Task values use - `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` and `on-peek` assert `=(src our)` — no cross-ship subscriptions or foreign scries. Core, gateway, and automation pokes are local only; lens applies its per-action source rules to admit trusted bot runs and owner relays. +- `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 diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/design.md b/openspec/changes/mirror-openclaw-automations-to-steward/design.md index 15061c7015..7e0e8a7889 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/design.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/design.md @@ -134,9 +134,10 @@ evolve independently. The OpenClaw harness is the submitting actor. `%steward` does not authenticate a distinct harness identity in this increment; it -authorizes the submission through the existing local Gall source -boundary and rejects foreign sources. The scry uses the same local -boundary. +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 diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md b/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md index 293c452931..12c3bf28ce 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/proposal.md @@ -14,9 +14,9 @@ work. definitions. - Migrate existing deployed `%steward` state to initialize the automation module without losing core, lens, or gateway state. -- Add a local, independently versioned `%project` automation action - that atomically commits a complete task projection as the current - stored snapshot. +- 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`. @@ -30,9 +30,10 @@ work. `cron_reconciled`, this is a best-effort mirror repaired on gateway startup and subsequent cron changes rather than an authoritative external projection. -- Add a local `%steward` scry that returns the complete stored task - projection as a JSON object keyed by task ID, without duplicating - IDs inside task values. +- 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. @@ -53,7 +54,7 @@ None. - Backend: a versioned `%steward` state migration, automation dispatch, new automation action and task-map marks, JSON - conversion, local scry handling, and Hoon tests. + 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. 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 index 8a5257824b..a599c97f50 100644 --- 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 @@ -220,30 +220,27 @@ fails. - **THEN** loading fails visibly rather than silently replacing existing data with default state -### Requirement: Local JSON task scry +### Requirement: JSON task scry -`%steward` SHALL expose a local-only scry at `/x/v1/automation/tasks` -that returns the complete currently stored task projection as JSON. -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. +`%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 local client scries `/x/v1/automation/tasks` after a - snapshot has been accepted +- **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 local client scries `/x/v1/automation/tasks` while the +- **WHEN** a client scries `/x/v1/automation/tasks` while the projection is empty - **THEN** it receives `{ "tasks": {} }` - -#### Scenario: Foreign client attempts to read tasks - -- **WHEN** a non-local source attempts the automation task scry -- **THEN** `%steward` rejects the request diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 04a974754f..0d0629da06 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -104,3 +104,5 @@ 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. From f03ec4212dce2d1952c731fe44783fc4633136fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Tue, 11 Aug 2026 10:48:52 +0800 Subject: [PATCH 50/62] steward: format automation JSON fixtures --- desk/tests/app/steward.hoon | 270 +++++++++++++++++- desk/tests/lib/steward-automation-json.hoon | 200 ++++++++++++- .../tasks.md | 2 + 3 files changed, 454 insertions(+), 18 deletions(-) diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index edea5ca922..372b02c58b 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -59,22 +59,280 @@ (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","text":"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","text":"Send a playful reminder."},"createdAtMs":1785735243782,"updatedAtMs":1785740230441}]}}' + ''' + { + "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", + "text": "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", + "text": "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","text":"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","text":"Send a playful reminder."},"createdAtMs":1785735243782,"updatedAtMs":1785740230441}}}' + ''' + { + "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", + "text": "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", + "text": "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","text":"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","text":"Send the paused reminder."},"createdAtMs":1785735243782,"updatedAtMs":1785735243782}]}}' + ''' + { + "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", + "text": "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", + "text": "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","text":"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","text":"Send the paused reminder."},"createdAtMs":1785735243782,"updatedAtMs":1785735243782}}}' + ''' + { + "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", + "text": "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", + "text": "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","text":"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","text":"Send the one-shot reminder."},"createdAtMs":1785740000000,"updatedAtMs":1785740000000}]}}' + ''' + { + "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", + "text": "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", + "text": "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","text":"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","text":"Send the one-shot reminder."},"createdAtMs":1785740000000,"updatedAtMs":1785740000000}}}' + ''' + { + "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", + "text": "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", + "text": "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 diff --git a/desk/tests/lib/steward-automation-json.hoon b/desk/tests/lib/steward-automation-json.hoon index e41759093e..e5c88623e3 100644 --- a/desk/tests/lib/steward-automation-json.hoon +++ b/desk/tests/lib/steward-automation-json.hoon @@ -78,10 +78,94 @@ == ++ 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","text":"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","text":"Send a playful reminder."},"createdAtMs":1785735243782,"updatedAtMs":1785740230441}]}}' + ''' + { + "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", + "text": "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", + "text": "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","text":"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","text":"Send a playful reminder."},"createdAtMs":1785735243782,"updatedAtMs":1785740230441}}}' + ''' + { + "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", + "text": "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", + "text": "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]]] @@ -108,7 +192,35 @@ :: ++ test-focused-cron-schedule-codec =/ body=@t - '{"project":{"tasks":[{"id":"cron-focused","agentId":"agent-1","name":"Daily summary","description":"Send the daily summary","enabled":true,"schedule":{"kind":"cron","expr":"0 9 * * *","tz":"UTC","staggerMs":30000},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","text":"Summarize activity"},"createdAtMs":1704067200000,"updatedAtMs":1704153600000}]}}' + ''' + { + "project": { + "tasks": [ + { + "id": "cron-focused", + "agentId": "agent-1", + "name": "Daily summary", + "description": "Send the daily summary", + "enabled": true, + "schedule": { + "kind": "cron", + "expr": "0 9 * * *", + "tz": "UTC", + "staggerMs": 30000 + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "text": "Summarize activity" + }, + "createdAtMs": 1704067200000, + "updatedAtMs": 1704153600000 + } + ] + } + } + ''' =/ expected=action:v1:a [%project ~[['cron-focused' cron-task]]] =/ actual=action:v1:a (parse-action body) ;: weld @@ -116,33 +228,82 @@ (expect-eq !>((parse-json body)) !>((action:enjs:aj actual))) == ++ test-empty-action-grab-and-grow - =/ body=@t '{"project":{"tasks":[]}}' + =/ 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 '{"project":{"tasks":[{"id":"empty"}]}}') + =/ actual=action:v1:a (parse-action body) ;: weld (expect-eq !>(expected) !>(actual)) %+ expect-eq - !>((parse-json '{"project":{"tasks":[{"id":"empty"}]}}')) + !>((parse-json body)) !>((action:enjs:aj actual)) == ++ test-invalid-json-rejected + =/ body=@t + ''' + { + "project": + ''' %- expect-fail - |. (parse-action '{"project":') + |. (parse-action body) ++ test-duplicate-action-ids-rejected %- expect-fail |. %- parse-action - '{"project":{"tasks":[{"id":"same"},{"id":"same"}]}}' + ''' + { + "project": { + "tasks": [ + { + "id": "same" + }, + { + "id": "same" + } + ] + } + } + ''' ++ test-invalid-schedule-kind-rejected %- expect-fail |. %- parse-action - '{"project":{"tasks":[{"id":"bad","schedule":{"kind":"once"}}]}}' + ''' + { + "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 @@ -153,7 +314,16 @@ =/ 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"}}}') + %- parse-json + ''' + { + "tasks": { + "map-id": { + "name": "Named task" + } + } + } + ''' =/ actual=json (task-map:enjs:aj tasks) ;: weld (expect-eq !>(expected) !>(actual)) @@ -161,7 +331,13 @@ == ++ test-empty-task-map-serializes-as-empty-object =/ tasks=task-map:v1:a *(map @t task:v1:a) - =/ expected=json (parse-json '{"tasks":{}}') + =/ 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/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 0d0629da06..9d52779585 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -106,3 +106,5 @@ 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. From 7b9cb7850a98ab760a72a45578f382cba73f0b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Tue, 11 Aug 2026 10:56:56 +0800 Subject: [PATCH 51/62] steward: use captured cron fixture --- desk/tests/lib/steward-automation-json.hoon | 43 +++++++++---------- .../tasks.md | 2 + packages/openclaw/src/fixtures/README.md | 4 +- ...penclaw-2026.5.28-cron-jobs.sanitized.json | 26 +++++++++++ .../src/steward-automation-projection.test.ts | 26 ++++++++++- 5 files changed, 75 insertions(+), 26 deletions(-) diff --git a/desk/tests/lib/steward-automation-json.hoon b/desk/tests/lib/steward-automation-json.hoon index e5c88623e3..db11b7a3e8 100644 --- a/desk/tests/lib/steward-automation-json.hoon +++ b/desk/tests/lib/steward-automation-json.hoon @@ -52,16 +52,16 @@ == ++ cron-task ^- task:v1:a - :* (some 'agent-1') - (some 'Daily summary') - (some 'Send the daily summary') - (some %.y) - (some [%cron (some '0 9 * * *') (some 'UTC') (some ~s30)]) + :* (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 'Summarize activity')]) - (some ~2024.1.1) - (some ~2024.1.2) + (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 @@ -187,8 +187,7 @@ !>((action:enjs:aj actual)) == :: -:: no cron-expression job was present in the captured runtime history. keep -:: this synthetic case focused on the third supported schedule codec +:: this case is normalized from a live pinned OpenClaw cron-expression capture :: ++ test-focused-cron-schedule-codec =/ body=@t @@ -197,31 +196,31 @@ "project": { "tasks": [ { - "id": "cron-focused", - "agentId": "agent-1", - "name": "Daily summary", - "description": "Send the daily summary", - "enabled": true, + "id": "trace-cron-1", + "agentId": "dev", + "name": "Captured weekday reminder", + "description": "Captured cron expression fixture", + "enabled": false, "schedule": { "kind": "cron", - "expr": "0 9 * * *", - "tz": "UTC", - "staggerMs": 30000 + "expr": "17 4 * * 1-5", + "tz": "America/New_York", + "staggerMs": 45000 }, "sessionTarget": "isolated", "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Summarize activity" + "text": "Send a weekday reminder." }, - "createdAtMs": 1704067200000, - "updatedAtMs": 1704153600000 + "createdAtMs": 1786416589889, + "updatedAtMs": 1786416589889 } ] } } ''' - =/ expected=action:v1:a [%project ~[['cron-focused' cron-task]]] + =/ expected=action:v1:a [%project ~[['trace-cron-1' cron-task]]] =/ actual=action:v1:a (parse-action body) ;: weld (expect-eq !>(expected) !>(actual)) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 9d52779585..2816aa3709 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -108,3 +108,5 @@ 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. diff --git a/packages/openclaw/src/fixtures/README.md b/packages/openclaw/src/fixtures/README.md index 2db65cee3f..5db4dcdcdc 100644 --- a/packages/openclaw/src/fixtures/README.md +++ b/packages/openclaw/src/fixtures/README.md @@ -1,5 +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. They came from session file `c87e8f5e-1a0c-4866-b967-5c3f44311ca7.jsonl`, jobs `0634ad7a-3ba1-4a65-b64a-db04658d8e64` (`at`) and `f8a8741a-af0f-4cf1-8da9-43faf429cc7b` (`every`). No captured cron-expression job was present. +`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, and runtime state shapes were retained. The fixture contains no tokens, secrets, or session keys. +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 index 255bf1dae4..294c10465d 100644 --- 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 @@ -63,5 +63,31 @@ "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-projection.test.ts b/packages/openclaw/src/steward-automation-projection.test.ts index 42080bc224..dff96030ae 100644 --- a/packages/openclaw/src/steward-automation-projection.test.ts +++ b/packages/openclaw/src/steward-automation-projection.test.ts @@ -12,7 +12,7 @@ function runtimeJob(value: unknown): HookCronJob { } describe('Steward automation projection normalization', () => { - it('normalizes captured at/every jobs with their optional fields', () => { + it('normalizes captured at/every/cron jobs with their optional fields', () => { const result = normalizeStewardAutomationProject( capturedCronJobs.map(runtimeJob) ); @@ -54,11 +54,33 @@ describe('Steward automation projection normalization', () => { 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', + text: '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('description'); expect(task).not.toHaveProperty('state'); expect(task).not.toHaveProperty('delivery'); expect(task).not.toHaveProperty('deleteAfterRun'); From cbfb9e173fb0cd3749fee500feb75cb05cd49e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Tue, 11 Aug 2026 12:29:43 +0800 Subject: [PATCH 52/62] steward: rename automation task payload type --- desk/lib/steward/automation-json.hoon | 4 ++-- desk/sur/steward/automation.hoon | 6 +++--- .../changes/mirror-openclaw-automations-to-steward/tasks.md | 2 ++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/desk/lib/steward/automation-json.hoon b/desk/lib/steward/automation-json.hoon index 79cba52147..62d4bee589 100644 --- a/desk/lib/steward/automation-json.hoon +++ b/desk/lib/steward/automation-json.hoon @@ -43,7 +43,7 @@ ~|(bad-schedule-kind+kind !!) ++ payload |= jon=json - ^- cron-payload:v1:a + ^- task-payload:v1:a :* (optional 'kind' so jon) (optional 'text' so jon) == @@ -130,7 +130,7 @@ (pairs fields) == ++ payload - |= payload=cron-payload:v1:a + |= payload=task-payload:v1:a ^- json =/ fields=(list [@t json]) ~ =. fields ?~(kind.payload fields [['kind' s+u.kind.payload] fields]) diff --git a/desk/sur/steward/automation.hoon b/desk/sur/steward/automation.hoon index 9c0b411154..6cd43d9403 100644 --- a/desk/sur/steward/automation.hoon +++ b/desk/sur/steward/automation.hoon @@ -10,9 +10,9 @@ [%at at=(unit @da)] [%every every=(unit @dr) anchor=(unit @da)] == -:: $cron-payload: the definition fields of an OpenClaw task payload +:: $task-payload: the definition fields of an OpenClaw task payload :: -+$ cron-payload ++$ task-payload $: kind=(unit @t) text=(unit @t) == @@ -28,7 +28,7 @@ schedule=(unit cron-schedule) session-target=(unit @t) wake-mode=(unit @t) - payload=(unit cron-payload) + payload=(unit task-payload) created-at=(unit @da) updated-at=(unit @da) == diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 2816aa3709..7998044a65 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -110,3 +110,5 @@ 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. From 27562cc32ea565c88a5b88c13828b4ac01b93aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Wed, 12 Aug 2026 07:29:13 +0800 Subject: [PATCH 53/62] openclaw: validate automation timestamps with zod --- .../tasks.md | 2 ++ .../src/steward-automation-projection.test.ts | 15 ++++++-- .../src/steward-automation-projection.ts | 35 +++++-------------- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 7998044a65..3355064aef 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -112,3 +112,5 @@ 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. diff --git a/packages/openclaw/src/steward-automation-projection.test.ts b/packages/openclaw/src/steward-automation-projection.test.ts index dff96030ae..abd036c532 100644 --- a/packages/openclaw/src/steward-automation-projection.test.ts +++ b/packages/openclaw/src/steward-automation-projection.test.ts @@ -149,11 +149,22 @@ describe('Steward automation projection normalization', () => { }); }); + it('normalizes an ISO datetime with a timezone offset', () => { + expect( + normalizeStewardAutomationProject([ + 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([ [ - 'invalid at date', + 'impossible at date', { id: 'bad-at', schedule: { kind: 'at', at: '2026-02-31T00:00:00Z' } }, - /expected an ISO timestamp/, + /cron job bad-at schedule\.at: expected an ISO timestamp/, ], [ 'invalid number', diff --git a/packages/openclaw/src/steward-automation-projection.ts b/packages/openclaw/src/steward-automation-projection.ts index 3c6dc29f2e..69168741a4 100644 --- a/packages/openclaw/src/steward-automation-projection.ts +++ b/packages/openclaw/src/steward-automation-projection.ts @@ -1,4 +1,5 @@ import type { PluginHookGatewayCronJob } from 'openclaw/plugin-sdk/types'; +import { z } from 'zod'; export type StewardAutomationSchedule = | { @@ -92,6 +93,11 @@ function optionalNaturalNumber( return value; } +const IsoTimestampMillisecondsSchema = z.iso + .datetime({ offset: true }) + .transform(Date.parse) + .pipe(z.number().int().safe().nonnegative()); + function optionalIsoTimestamp( value: unknown, field: string @@ -99,34 +105,11 @@ function optionalIsoTimestamp( if (value === undefined) { return undefined; } - const timestamp = requiredString(value, field); - const parts = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(?:Z|[+-](\d{2}):(\d{2}))$/.exec( - timestamp - ); - const milliseconds = Date.parse(timestamp); - if (!parts || !Number.isSafeInteger(milliseconds) || milliseconds < 0) { - throw new Error(`Invalid ${field}: expected an ISO timestamp`); - } - const [, yearText, monthText, dayText, hourText, minuteText, secondText] = - parts; - const year = Number(yearText); - const month = Number(monthText); - const day = Number(dayText); - const validCalendarDate = - month >= 1 && - month <= 12 && - day >= 1 && - day <= new Date(Date.UTC(year, month, 0)).getUTCDate() && - Number(hourText) <= 23 && - Number(minuteText) <= 59 && - Number(secondText) <= 59 && - (parts[7] === undefined || Number(parts[7]) <= 23) && - (parts[8] === undefined || Number(parts[8]) <= 59); - if (!validCalendarDate) { + const parsed = IsoTimestampMillisecondsSchema.safeParse(value); + if (!parsed.success) { throw new Error(`Invalid ${field}: expected an ISO timestamp`); } - return milliseconds; + return parsed.data; } function normalizeSchedule( From 6b04c6f71d72d9e6098587754f21b45fd80665fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Wed, 12 Aug 2026 14:51:35 +0800 Subject: [PATCH 54/62] openclaw: validate automation jobs with zod --- .../tasks.md | 2 + .../src/steward-automation-projection.test.ts | 72 ++++ .../src/steward-automation-projection.ts | 322 ++++++++---------- 3 files changed, 223 insertions(+), 173 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 3355064aef..21fb8206a6 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -114,3 +114,5 @@ 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. diff --git a/packages/openclaw/src/steward-automation-projection.test.ts b/packages/openclaw/src/steward-automation-projection.test.ts index abd036c532..885310d531 100644 --- a/packages/openclaw/src/steward-automation-projection.test.ts +++ b/packages/openclaw/src/steward-automation-projection.test.ts @@ -137,6 +137,67 @@ describe('Steward automation projection normalization', () => { expect(task.payload).not.toHaveProperty('message'); }); + it('omits explicitly undefined task and schedule fields', () => { + const tasks = normalizeStewardAutomationProject([ + 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 project', () => { expect( normalizeStewardAutomationProject([ @@ -161,6 +222,17 @@ describe('Steward automation projection normalization', () => { }); 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' } }, diff --git a/packages/openclaw/src/steward-automation-projection.ts b/packages/openclaw/src/steward-automation-projection.ts index 69168741a4..65a1e8efaa 100644 --- a/packages/openclaw/src/steward-automation-projection.ts +++ b/packages/openclaw/src/steward-automation-projection.ts @@ -43,189 +43,165 @@ export interface StewardAutomationProjectAction { }; } -type UnknownRecord = Record; - -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function requireRecord(value: unknown, field: string): UnknownRecord { - if (!isRecord(value)) { - throw new Error(`Invalid ${field}: expected an object`); - } - return value; -} +const ExpectedStringSchema = z.string({ error: 'expected a string' }); +const ExpectedBooleanSchema = z.boolean({ error: 'expected a boolean' }); +const NaturalNumberSchema = z + .number({ error: 'expected a non-negative safe integer' }) + .int({ error: 'expected a non-negative safe integer' }) + .safe({ error: 'expected a non-negative safe integer' }) + .nonnegative({ error: 'expected a non-negative safe integer' }); +const IsoTimestampMillisecondsSchema = z.iso + .datetime({ offset: true, error: 'expected an ISO timestamp' }) + .transform(Date.parse) + .pipe( + z + .number({ error: 'expected an ISO timestamp' }) + .int({ error: 'expected an ISO timestamp' }) + .safe({ error: 'expected an ISO timestamp' }) + .nonnegative({ error: 'expected an ISO timestamp' }) + ); -function requiredString(value: unknown, field: string): string { - if (typeof value !== 'string') { - throw new Error(`Invalid ${field}: expected a string`); - } - return value; -} +const PayloadSchema = z + .object({ + kind: ExpectedStringSchema.optional(), + text: ExpectedStringSchema.optional(), + message: ExpectedStringSchema.optional(), + }) + .transform(({ kind, text: declaredText, message: runtimeMessage }) => { + // The pinned declaration says `text`; captured 2026.5.28 runtime values + // use `message`. Validate both aliases and expose only Steward's `text`. + const text = declaredText ?? runtimeMessage; + return { + ...(kind === undefined ? {} : { kind }), + ...(text === undefined ? {} : { text }), + } satisfies StewardAutomationPayload; + }); -function optionalString(value: unknown, field: string): string | undefined { - if (value === undefined) { - return undefined; - } - return requiredString(value, field); -} +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: ExpectedBooleanSchema.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 }), + }) + ); -function optionalBoolean(value: unknown, field: string): boolean | undefined { - if (value === undefined) { - return undefined; +type NormalizedCronJob = z.infer; + +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}` + ); } - if (typeof value !== 'boolean') { - throw new Error(`Invalid ${field}: expected a boolean`); - } - return value; -} -function optionalNaturalNumber( - value: unknown, - field: string -): number | undefined { - if (value === undefined) { - return undefined; + 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'; } - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { - throw new Error(`Invalid ${field}: expected a non-negative safe integer`); - } - return value; + return new Error(`Invalid ${field}: ${message}`); } -const IsoTimestampMillisecondsSchema = z.iso - .datetime({ offset: true }) - .transform(Date.parse) - .pipe(z.number().int().safe().nonnegative()); - -function optionalIsoTimestamp( - value: unknown, - field: string -): number | undefined { - if (value === undefined) { - return undefined; - } - const parsed = IsoTimestampMillisecondsSchema.safeParse(value); +function normalizeTask(job: PluginHookGatewayCronJob): StewardAutomationTask { + const parsed = CronJobSchema.safeParse(job); if (!parsed.success) { - throw new Error(`Invalid ${field}: expected an ISO timestamp`); + throw formatCronJobError(parsed.error, job); } - return parsed.data; -} - -function normalizeSchedule( - value: unknown, - jobId: string -): StewardAutomationSchedule | undefined { - if (value === undefined) { - return undefined; - } - const schedule = requireRecord(value, `cron job ${jobId} schedule`); - const field = (name: string) => `cron job ${jobId} schedule.${name}`; - - switch (schedule.kind) { - case 'cron': { - const expr = optionalString(schedule.expr, field('expr')); - const tz = optionalString(schedule.tz, field('tz')); - const staggerMs = optionalNaturalNumber( - schedule.staggerMs, - field('staggerMs') - ); - return { - kind: 'cron', - ...(expr === undefined ? {} : { expr }), - ...(tz === undefined ? {} : { tz }), - ...(staggerMs === undefined ? {} : { staggerMs }), - }; - } - case 'at': { - const at = optionalIsoTimestamp(schedule.at, field('at')); - return { - kind: 'at', - ...(at === undefined ? {} : { at }), - }; - } - case 'every': { - const everyMs = optionalNaturalNumber(schedule.everyMs, field('everyMs')); - const anchorMs = optionalNaturalNumber( - schedule.anchorMs, - field('anchorMs') - ); - return { - kind: 'every', - ...(everyMs === undefined ? {} : { everyMs }), - ...(anchorMs === undefined ? {} : { anchorMs }), - }; - } - default: - throw new Error( - `Invalid cron job ${jobId} schedule.kind: unsupported value ${String(schedule.kind)}` - ); - } -} - -function normalizePayload( - value: unknown, - jobId: string -): StewardAutomationPayload | undefined { - if (value === undefined) { - return undefined; - } - const payload = requireRecord(value, `cron job ${jobId} payload`); - const kind = optionalString(payload.kind, `cron job ${jobId} payload.kind`); - // The pinned declaration says `text`; captured 2026.5.28 runtime values use - // `message`. Validate both aliases and expose only Steward's `text`. - const declaredText = optionalString( - payload.text, - `cron job ${jobId} payload.text` - ); - const runtimeMessage = optionalString( - payload.message, - `cron job ${jobId} payload.message` - ); - const text = declaredText ?? runtimeMessage; - return { - ...(kind === undefined ? {} : { kind }), - ...(text === undefined ? {} : { text }), - }; -} - -function normalizeTask(job: PluginHookGatewayCronJob): StewardAutomationTask { - const source = requireRecord(job, 'cron job'); - const id = requiredString(source.id, 'cron job id'); - const field = (name: string) => `cron job ${id} ${name}`; - const agentId = optionalString(source.agentId, field('agentId')); - const name = optionalString(source.name, field('name')); - const description = optionalString(source.description, field('description')); - const enabled = optionalBoolean(source.enabled, field('enabled')); - const schedule = normalizeSchedule(source.schedule, id); - const sessionTarget = optionalString( - source.sessionTarget, - field('sessionTarget') - ); - const wakeMode = optionalString(source.wakeMode, field('wakeMode')); - const payload = normalizePayload(source.payload, id); - const createdAtMs = optionalNaturalNumber( - source.createdAtMs, - field('createdAtMs') - ); - const updatedAtMs = optionalNaturalNumber( - source.updatedAtMs, - field('updatedAtMs') - ); - - return { - 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 }), - }; + const task: NormalizedCronJob = parsed.data; + return task; } /** Normalize one complete OpenClaw cron list into Steward's `%project` JSON. */ From 1884355a329a26b14142a596adfa82c1691f70f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Wed, 12 Aug 2026 22:56:38 +0800 Subject: [PATCH 55/62] steward: minor fixes --- desk/app/steward.hoon | 5 +++-- desk/lib/steward/automation.hoon | 7 +++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/desk/app/steward.hoon b/desk/app/steward.hoon index 0690dea78e..52b327992c 100644 --- a/desk/app/steward.hoon +++ b/desk/app/steward.hoon @@ -671,8 +671,9 @@ [%v1 %tasks ~] ``steward-automation-task-map-1+!>(tasks.automation.state) == - :: build the complete replacement before mutating state. a duplicate ID - :: crashes here, leaving the previous projection untouched + :: 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) diff --git a/desk/lib/steward/automation.hoon b/desk/lib/steward/automation.hoon index 9b8d361b70..cf9357efc0 100644 --- a/desk/lib/steward/automation.hoon +++ b/desk/lib/steward/automation.hoon @@ -4,7 +4,6 @@ :: integer milliseconds. these wrappers use the standard conversions supplied :: by zuse. :: -=* z ..zuse |% :: +milliseconds-to-duration: convert integer milliseconds to an Urbit duration :: @@ -17,17 +16,17 @@ ++ duration-to-milliseconds |= duration=@dr ^- @ud - (msec:milly:z duration) + (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:z milliseconds) + (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:z date) + (unm:chrono:userlib date) -- From e32f7d55cc268f7897ac87755950fadb8dc560e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Wed, 12 Aug 2026 22:56:53 +0800 Subject: [PATCH 56/62] gitignore: bump --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 7054eb6db4..0a017869a0 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ clurd .obsidian .pi + +tmuxp.yaml From 188760c542d3fb0df9b2fa7a23a4401dfea8f73e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Thu, 13 Aug 2026 07:15:27 +0800 Subject: [PATCH 57/62] steward: canonicalize payload fields --- desk/lib/steward/automation-json.hoon | 4 +-- desk/sur/steward/automation.hoon | 2 +- desk/tests/app/steward.hoon | 24 ++++++------- desk/tests/lib/steward-automation-json.hoon | 10 +++--- docs/backend/desk/app/steward.md | 4 +-- .../tasks.md | 2 ++ .../src/steward-automation-adapter.test.ts | 2 +- .../src/steward-automation-projection.test.ts | 35 ++++++++++++------- .../src/steward-automation-projection.ts | 14 ++++---- .../steward-automation-reconciliation.test.ts | 16 ++++----- 10 files changed, 63 insertions(+), 50 deletions(-) diff --git a/desk/lib/steward/automation-json.hoon b/desk/lib/steward/automation-json.hoon index 62d4bee589..458c434149 100644 --- a/desk/lib/steward/automation-json.hoon +++ b/desk/lib/steward/automation-json.hoon @@ -45,7 +45,7 @@ |= jon=json ^- task-payload:v1:a :* (optional 'kind' so jon) - (optional 'text' so jon) + (optional 'message' so jon) == ++ task |= jon=json @@ -134,7 +134,7 @@ ^- json =/ fields=(list [@t json]) ~ =. fields ?~(kind.payload fields [['kind' s+u.kind.payload] fields]) - =. fields ?~(text.payload fields [['text' s+u.text.payload] fields]) + =. fields ?~(message.payload fields [['message' s+u.message.payload] fields]) (pairs fields) ++ task |= =task:v1:a diff --git a/desk/sur/steward/automation.hoon b/desk/sur/steward/automation.hoon index 6cd43d9403..444c76e7be 100644 --- a/desk/sur/steward/automation.hoon +++ b/desk/sur/steward/automation.hoon @@ -14,7 +14,7 @@ :: +$ task-payload $: kind=(unit @t) - text=(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 diff --git a/desk/tests/app/steward.hoon b/desk/tests/app/steward.hoon index 372b02c58b..66e1605897 100644 --- a/desk/tests/app/steward.hoon +++ b/desk/tests/app/steward.hoon @@ -76,7 +76,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send a short reminder." + "message": "Send a short reminder." }, "createdAtMs": 1785734006665, "updatedAtMs": 1785734006665 @@ -95,7 +95,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send a playful reminder." + "message": "Send a playful reminder." }, "createdAtMs": 1785735243782, "updatedAtMs": 1785740230441 @@ -121,7 +121,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send a short reminder." + "message": "Send a short reminder." }, "createdAtMs": 1785734006665, "updatedAtMs": 1785734006665 @@ -139,7 +139,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send a playful reminder." + "message": "Send a playful reminder." }, "createdAtMs": 1785735243782, "updatedAtMs": 1785740230441 @@ -168,7 +168,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send the daily status." + "message": "Send the daily status." }, "createdAtMs": 1785734000000, "updatedAtMs": 1785734000000 @@ -187,7 +187,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send the paused reminder." + "message": "Send the paused reminder." }, "createdAtMs": 1785735243782, "updatedAtMs": 1785735243782 @@ -215,7 +215,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send the daily status." + "message": "Send the daily status." }, "createdAtMs": 1785734000000, "updatedAtMs": 1785734000000 @@ -233,7 +233,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send the paused reminder." + "message": "Send the paused reminder." }, "createdAtMs": 1785735243782, "updatedAtMs": 1785735243782 @@ -262,7 +262,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send the updated daily status." + "message": "Send the updated daily status." }, "createdAtMs": 1785734000000, "updatedAtMs": 1785740000000 @@ -280,7 +280,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send the one-shot reminder." + "message": "Send the one-shot reminder." }, "createdAtMs": 1785740000000, "updatedAtMs": 1785740000000 @@ -308,7 +308,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send the updated daily status." + "message": "Send the updated daily status." }, "createdAtMs": 1785734000000, "updatedAtMs": 1785740000000 @@ -325,7 +325,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send the one-shot reminder." + "message": "Send the one-shot reminder." }, "createdAtMs": 1785740000000, "updatedAtMs": 1785740000000 diff --git a/desk/tests/lib/steward-automation-json.hoon b/desk/tests/lib/steward-automation-json.hoon index db11b7a3e8..5e4ab09731 100644 --- a/desk/tests/lib/steward-automation-json.hoon +++ b/desk/tests/lib/steward-automation-json.hoon @@ -95,7 +95,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send a short reminder." + "message": "Send a short reminder." }, "createdAtMs": 1785734006665, "updatedAtMs": 1785734006665 @@ -114,7 +114,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send a playful reminder." + "message": "Send a playful reminder." }, "createdAtMs": 1785735243782, "updatedAtMs": 1785740230441 @@ -140,7 +140,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send a short reminder." + "message": "Send a short reminder." }, "createdAtMs": 1785734006665, "updatedAtMs": 1785734006665 @@ -158,7 +158,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send a playful reminder." + "message": "Send a playful reminder." }, "createdAtMs": 1785735243782, "updatedAtMs": 1785740230441 @@ -211,7 +211,7 @@ "wakeMode": "now", "payload": { "kind": "agentTurn", - "text": "Send a weekday reminder." + "message": "Send a weekday reminder." }, "createdAtMs": 1786416589889, "updatedAtMs": 1786416589889 diff --git a/docs/backend/desk/app/steward.md b/docs/backend/desk/app/steward.md index 23db64fbb7..ab8e09b312 100644 --- a/docs/backend/desk/app/steward.md +++ b/docs/backend/desk/app/steward.md @@ -103,7 +103,7 @@ The v1 state is `tasks=(map @t task)`. The OpenClaw job ID is used only as the m | enabled state | `(unit ?)` | `enabled` | | schedule | `(unit cron-schedule)` | `schedule` | | execution target | `(unit @t)` for each value | `sessionTarget`, `wakeMode` | -| payload definition | optional `kind` and `text` | `payload` | +| 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. @@ -128,7 +128,7 @@ The inbound action and outbound task map use separate, independently versioned m }, "payload": { "kind": "agentTurn", - "text": "Send the daily status." + "message": "Send the daily status." } } ] diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 21fb8206a6..490bcd368d 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -116,3 +116,5 @@ ISO datetime validation and focused projection tests. - [x] 5.12 Replace the remaining projection field helpers with complete Zod task, schedule, and payload schemas. +- [ ] 5.13 Make OpenClaw `message` the canonical agent-turn payload + field, retaining `text` only as a compatibility fallback. diff --git a/packages/openclaw/src/steward-automation-adapter.test.ts b/packages/openclaw/src/steward-automation-adapter.test.ts index 9a319220d2..ecbaef78b7 100644 --- a/packages/openclaw/src/steward-automation-adapter.test.ts +++ b/packages/openclaw/src/steward-automation-adapter.test.ts @@ -19,7 +19,7 @@ const action: StewardAutomationProjectAction = { { id: 'job-1', enabled: false, - payload: { kind: 'agentTurn', text: 'check status' }, + payload: { kind: 'agentTurn', message: 'check status' }, }, ], }, diff --git a/packages/openclaw/src/steward-automation-projection.test.ts b/packages/openclaw/src/steward-automation-projection.test.ts index 885310d531..f582f5fbdc 100644 --- a/packages/openclaw/src/steward-automation-projection.test.ts +++ b/packages/openclaw/src/steward-automation-projection.test.ts @@ -30,7 +30,7 @@ describe('Steward automation projection normalization', () => { wakeMode: 'now', payload: { kind: 'agentTurn', - text: 'Send a short reminder.', + message: 'Send a short reminder.', }, createdAtMs: 1_785_734_006_665, updatedAtMs: 1_785_734_006_665, @@ -49,7 +49,7 @@ describe('Steward automation projection normalization', () => { wakeMode: 'now', payload: { kind: 'agentTurn', - text: 'Send a playful reminder.', + message: 'Send a playful reminder.', }, createdAtMs: 1_785_735_243_782, updatedAtMs: 1_785_740_230_441, @@ -70,7 +70,7 @@ describe('Steward automation projection normalization', () => { wakeMode: 'now', payload: { kind: 'agentTurn', - text: 'Send a weekday reminder.', + message: 'Send a weekday reminder.', }, createdAtMs: 1_786_416_589_889, updatedAtMs: 1_786_416_589_889, @@ -85,11 +85,11 @@ describe('Steward automation projection normalization', () => { expect(task).not.toHaveProperty('delivery'); expect(task).not.toHaveProperty('deleteAfterRun'); expect(task).not.toHaveProperty('sessionKey'); - expect(task.payload).not.toHaveProperty('message'); + expect(task.payload).not.toHaveProperty('text'); } }); - it('includes a synthetic disabled cron job and preserves false and zero', () => { + it('prefers canonical message and preserves false and zero', () => { const result = normalizeStewardAutomationProject([ runtimeJob({ id: 'disabled-zero', @@ -102,8 +102,8 @@ describe('Steward automation projection normalization', () => { }, payload: { kind: '', - text: 'declared text', - message: 'runtime message', + text: 'compatibility text', + message: 'canonical message', unknown: 'drop me', }, createdAtMs: 0, @@ -122,7 +122,7 @@ describe('Steward automation projection normalization', () => { id: 'disabled-zero', enabled: false, schedule: { kind: 'cron', expr: '', tz: '', staggerMs: 0 }, - payload: { kind: '', text: 'declared text' }, + payload: { kind: '', message: 'canonical message' }, createdAtMs: 0, updatedAtMs: 0, }, @@ -134,7 +134,18 @@ describe('Steward automation projection normalization', () => { expect(task).not.toHaveProperty('delivery'); expect(task).not.toHaveProperty('deleteAfterRun'); expect(task).not.toHaveProperty('sessionKey'); - expect(task.payload).not.toHaveProperty('message'); + expect(task.payload).not.toHaveProperty('text'); + }); + + it('uses compatibility text when canonical message is absent', () => { + expect( + normalizeStewardAutomationProject([ + 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', () => { @@ -249,12 +260,12 @@ describe('Steward automation projection normalization', () => { /unsupported value on-exit/, ], [ - 'invalid declared payload text', - { id: 'bad-text', payload: { text: 1, message: 'fallback' } }, + 'invalid compatibility payload text', + { id: 'bad-text', payload: { text: 1 } }, /payload.text: expected a string/, ], [ - 'invalid runtime payload message', + 'invalid canonical payload message', { id: 'bad-message', payload: { message: false } }, /payload.message: expected a string/, ], diff --git a/packages/openclaw/src/steward-automation-projection.ts b/packages/openclaw/src/steward-automation-projection.ts index 65a1e8efaa..6f210d4982 100644 --- a/packages/openclaw/src/steward-automation-projection.ts +++ b/packages/openclaw/src/steward-automation-projection.ts @@ -20,7 +20,7 @@ export type StewardAutomationSchedule = export interface StewardAutomationPayload { kind?: string; - text?: string; + message?: string; } export interface StewardAutomationTask { @@ -64,16 +64,16 @@ const IsoTimestampMillisecondsSchema = z.iso const PayloadSchema = z .object({ kind: ExpectedStringSchema.optional(), - text: ExpectedStringSchema.optional(), message: ExpectedStringSchema.optional(), + text: ExpectedStringSchema.optional(), }) - .transform(({ kind, text: declaredText, message: runtimeMessage }) => { - // The pinned declaration says `text`; captured 2026.5.28 runtime values - // use `message`. Validate both aliases and expose only Steward's `text`. - const text = declaredText ?? runtimeMessage; + .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 }), - ...(text === undefined ? {} : { text }), + ...(message === undefined ? {} : { message }), } satisfies StewardAutomationPayload; }); diff --git a/packages/openclaw/src/steward-automation-reconciliation.test.ts b/packages/openclaw/src/steward-automation-reconciliation.test.ts index eb223578e4..f0b8987447 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.test.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -59,7 +59,7 @@ function job(id: string): PluginHookGatewayCronJob { return { id, enabled: true, - payload: { kind: 'agentTurn', text: id }, + payload: { kind: 'agentTurn', message: id }, }; } @@ -81,7 +81,7 @@ const jobs = [ name: 'Nightly status', enabled: false, schedule: { kind: 'cron', expr: '0 1 * * *', tz: 'UTC' }, - payload: { kind: 'agentTurn', text: 'check status' }, + payload: { kind: 'agentTurn', message: 'check status' }, state: { lastRunStatus: 'ok', lastRunAtMs: 1_777_000_000_000 }, createdAtMs: 1_700_000_000_000, }, @@ -117,7 +117,7 @@ describe('reconcileStewardAutomation', () => { name: 'Nightly status', enabled: false, schedule: { kind: 'cron', expr: '0 1 * * *', tz: 'UTC' }, - payload: { kind: 'agentTurn', text: 'check status' }, + payload: { kind: 'agentTurn', message: 'check status' }, createdAtMs: 1_700_000_000_000, }, ], @@ -696,12 +696,12 @@ describe('registerStewardAutomationReconciliationHooks', () => { { id: 'complete-enabled', enabled: true, - payload: { kind: 'agentTurn', text: 'first in complete list' }, + payload: { kind: 'agentTurn', message: 'first in complete list' }, }, { id: 'complete-disabled', enabled: false, - payload: { kind: 'agentTurn', text: 'second in complete list' }, + payload: { kind: 'agentTurn', message: 'second in complete list' }, }, ] satisfies PluginHookGatewayCronJob[]; const { context, list } = cronContext(completeJobs); @@ -727,7 +727,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { job: { id: 'event-only', enabled: true, - payload: { kind: 'agentTurn', text: 'event delta' }, + payload: { kind: 'agentTurn', message: 'event delta' }, state: { lastRunStatus: 'ok' }, }, }, @@ -748,7 +748,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { enabled: true, payload: { kind: 'agentTurn', - text: 'first in complete list', + message: 'first in complete list', }, }, { @@ -756,7 +756,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { enabled: false, payload: { kind: 'agentTurn', - text: 'second in complete list', + message: 'second in complete list', }, }, ], From 7449ed72b2409c99fb821f92512d2ef8d3582bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Thu, 13 Aug 2026 07:31:52 +0800 Subject: [PATCH 58/62] openclaw: rename steward projection symbols --- .../tasks.md | 2 + .../src/steward-automation-adapter.test.ts | 32 +++-- .../src/steward-automation-adapter.ts | 8 +- .../src/steward-automation-projection.test.ts | 30 ++-- .../src/steward-automation-projection.ts | 6 +- .../steward-automation-reconciliation.test.ts | 128 +++++++++--------- .../src/steward-automation-reconciliation.ts | 8 +- 7 files changed, 110 insertions(+), 104 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 490bcd368d..73973d8a76 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -118,3 +118,5 @@ complete Zod task, schedule, and payload schemas. - [ ] 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. diff --git a/packages/openclaw/src/steward-automation-adapter.test.ts b/packages/openclaw/src/steward-automation-adapter.test.ts index ecbaef78b7..294d38decf 100644 --- a/packages/openclaw/src/steward-automation-adapter.test.ts +++ b/packages/openclaw/src/steward-automation-adapter.test.ts @@ -7,13 +7,13 @@ import { import { sharedSlot } from './shared-state.js'; import { StewardAutomationConnectionUnavailableError, - submitStewardAutomationProject, + submitStewardAutomationProjection, } from './steward-automation-adapter.js'; -import type { StewardAutomationProjectAction } from './steward-automation-projection.js'; +import type { StewardAutomationProjection } from './steward-automation-projection.js'; const paramsSlot = sharedSlot(API_CLIENT_PARAMS_SLOT); -const action: StewardAutomationProjectAction = { +const projection: StewardAutomationProjection = { project: { tasks: [ { @@ -35,7 +35,7 @@ function paramsWithPoke( }; } -describe('submitStewardAutomationProject', () => { +describe('submitStewardAutomationProjection', () => { beforeEach(() => { paramsSlot.set(null); }); @@ -48,18 +48,18 @@ describe('submitStewardAutomationProject', () => { const poke = vi.fn().mockResolvedValue(42); paramsSlot.set(paramsWithPoke(poke)); - await submitStewardAutomationProject(action); + await submitStewardAutomationProjection(projection); expect(poke).toHaveBeenCalledOnce(); expect(poke).toHaveBeenCalledWith({ app: 'steward', mark: 'steward-automation-action-1', - json: action, + json: projection, }); }); it('fails with a retryable availability error when no connection is published', async () => { - const submission = submitStewardAutomationProject(action); + const submission = submitStewardAutomationProjection(projection); await expect(submission).rejects.toMatchObject({ name: 'StewardAutomationConnectionUnavailableError', @@ -81,9 +81,11 @@ describe('submitStewardAutomationProject', () => { paramsSlot.set(paramsWithPoke(poke)); let settled = false; - const submission = submitStewardAutomationProject(action).then(() => { - settled = true; - }); + const submission = submitStewardAutomationProjection(projection).then( + () => { + settled = true; + } + ); await Promise.resolve(); expect(poke).toHaveBeenCalledOnce(); @@ -99,7 +101,9 @@ describe('submitStewardAutomationProject', () => { const poke = vi.fn().mockRejectedValue(nack); paramsSlot.set(paramsWithPoke(poke)); - await expect(submitStewardAutomationProject(action)).rejects.toBe(nack); + await expect(submitStewardAutomationProjection(projection)).rejects.toBe( + nack + ); }); it('looks up the current slot value for every submission', async () => { @@ -107,16 +111,16 @@ describe('submitStewardAutomationProject', () => { const currentPoke = vi.fn().mockResolvedValue(2); paramsSlot.set(paramsWithPoke(stalePoke)); - await submitStewardAutomationProject(action); + await submitStewardAutomationProjection(projection); paramsSlot.set(paramsWithPoke(currentPoke)); - await submitStewardAutomationProject(action); + await submitStewardAutomationProjection(projection); expect(stalePoke).toHaveBeenCalledOnce(); expect(currentPoke).toHaveBeenCalledOnce(); expect(currentPoke).toHaveBeenCalledWith({ app: 'steward', mark: 'steward-automation-action-1', - json: action, + json: projection, }); }); }); diff --git a/packages/openclaw/src/steward-automation-adapter.ts b/packages/openclaw/src/steward-automation-adapter.ts index cb7ef5eace..8ce9ca6558 100644 --- a/packages/openclaw/src/steward-automation-adapter.ts +++ b/packages/openclaw/src/steward-automation-adapter.ts @@ -3,7 +3,7 @@ import { type SharedApiClientParams, } from './gateway-status.js'; import { sharedSlot } from './shared-state.js'; -import type { StewardAutomationProjectAction } from './steward-automation-projection.js'; +import type { StewardAutomationProjection } from './steward-automation-projection.js'; const apiClientParamsSlot = sharedSlot( API_CLIENT_PARAMS_SLOT @@ -22,8 +22,8 @@ export class StewardAutomationConnectionUnavailableError extends Error { } /** Submit one complete automation projection through the current monitor. */ -export async function submitStewardAutomationProject( - action: StewardAutomationProjectAction +export async function submitStewardAutomationProjection( + projection: StewardAutomationProjection ): Promise { const params = apiClientParamsSlot.get(); if (!params) { @@ -33,6 +33,6 @@ export async function submitStewardAutomationProject( await params.poke({ app: 'steward', mark: 'steward-automation-action-1', - json: action, + json: projection, }); } diff --git a/packages/openclaw/src/steward-automation-projection.test.ts b/packages/openclaw/src/steward-automation-projection.test.ts index f582f5fbdc..1d4723d456 100644 --- a/packages/openclaw/src/steward-automation-projection.test.ts +++ b/packages/openclaw/src/steward-automation-projection.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; import capturedCronJobs from './fixtures/openclaw-2026.5.28-cron-jobs.sanitized.json'; -import { normalizeStewardAutomationProject } from './steward-automation-projection.js'; +import { normalizeStewardAutomationProjection } from './steward-automation-projection.js'; type HookCronJob = Parameters< - typeof normalizeStewardAutomationProject + typeof normalizeStewardAutomationProjection >[0][number]; function runtimeJob(value: unknown): HookCronJob { @@ -13,7 +13,7 @@ function runtimeJob(value: unknown): HookCronJob { describe('Steward automation projection normalization', () => { it('normalizes captured at/every/cron jobs with their optional fields', () => { - const result = normalizeStewardAutomationProject( + const result = normalizeStewardAutomationProjection( capturedCronJobs.map(runtimeJob) ); @@ -90,7 +90,7 @@ describe('Steward automation projection normalization', () => { }); it('prefers canonical message and preserves false and zero', () => { - const result = normalizeStewardAutomationProject([ + const result = normalizeStewardAutomationProjection([ runtimeJob({ id: 'disabled-zero', enabled: false, @@ -139,7 +139,7 @@ describe('Steward automation projection normalization', () => { it('uses compatibility text when canonical message is absent', () => { expect( - normalizeStewardAutomationProject([ + normalizeStewardAutomationProjection([ runtimeJob({ id: 'compatibility-text', payload: { kind: 'agentTurn', text: 'fallback message' }, @@ -149,7 +149,7 @@ describe('Steward automation projection normalization', () => { }); it('omits explicitly undefined task and schedule fields', () => { - const tasks = normalizeStewardAutomationProject([ + const tasks = normalizeStewardAutomationProjection([ runtimeJob({ id: 'undefined-task-fields', agentId: undefined, @@ -209,21 +209,21 @@ describe('Steward automation projection normalization', () => { expect(tasks[2]?.schedule).not.toHaveProperty('anchorMs'); }); - it('preserves input order and returns a complete empty project', () => { + it('preserves input order and returns a complete empty projection', () => { expect( - normalizeStewardAutomationProject([ + normalizeStewardAutomationProjection([ runtimeJob({ id: 'second' }), runtimeJob({ id: 'first' }), ]).project.tasks.map(({ id }) => id) ).toEqual(['second', 'first']); - expect(normalizeStewardAutomationProject([])).toEqual({ + expect(normalizeStewardAutomationProjection([])).toEqual({ project: { tasks: [] }, }); }); it('normalizes an ISO datetime with a timezone offset', () => { expect( - normalizeStewardAutomationProject([ + normalizeStewardAutomationProjection([ runtimeJob({ id: 'offset-at', schedule: { kind: 'at', at: '2026-08-01T14:30:00+02:00' }, @@ -270,14 +270,14 @@ describe('Steward automation projection normalization', () => { /payload.message: expected a string/, ], ])('rejects %s', (_name, job, error) => { - expect(() => normalizeStewardAutomationProject([runtimeJob(job)])).toThrow( - error - ); + expect(() => + normalizeStewardAutomationProjection([runtimeJob(job)]) + ).toThrow(error); }); - it('rejects duplicate IDs before producing a project action', () => { + it('rejects duplicate IDs before producing a projection action', () => { expect(() => - normalizeStewardAutomationProject([ + normalizeStewardAutomationProjection([ runtimeJob({ id: 'duplicate' }), runtimeJob({ id: 'duplicate' }), ]) diff --git a/packages/openclaw/src/steward-automation-projection.ts b/packages/openclaw/src/steward-automation-projection.ts index 6f210d4982..a072011eef 100644 --- a/packages/openclaw/src/steward-automation-projection.ts +++ b/packages/openclaw/src/steward-automation-projection.ts @@ -37,7 +37,7 @@ export interface StewardAutomationTask { updatedAtMs?: number; } -export interface StewardAutomationProjectAction { +export interface StewardAutomationProjection { project: { tasks: StewardAutomationTask[]; }; @@ -205,9 +205,9 @@ function normalizeTask(job: PluginHookGatewayCronJob): StewardAutomationTask { } /** Normalize one complete OpenClaw cron list into Steward's `%project` JSON. */ -export function normalizeStewardAutomationProject( +export function normalizeStewardAutomationProjection( jobs: readonly PluginHookGatewayCronJob[] -): StewardAutomationProjectAction { +): StewardAutomationProjection { const seenIds = new Set(); const tasks = jobs.map((job) => { const task = normalizeTask(job); diff --git a/packages/openclaw/src/steward-automation-reconciliation.test.ts b/packages/openclaw/src/steward-automation-reconciliation.test.ts index f0b8987447..6671da83bc 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.test.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -5,7 +5,7 @@ import type { } from 'openclaw/plugin-sdk/types'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { submitStewardAutomationProject } from './steward-automation-adapter.js'; +import { submitStewardAutomationProjection } from './steward-automation-adapter.js'; import { DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS, StewardAutomationCronUnavailableError, @@ -18,7 +18,7 @@ import { } from './steward-automation-reconciliation.js'; vi.mock('./steward-automation-adapter.js', () => ({ - submitStewardAutomationProject: vi.fn(), + submitStewardAutomationProjection: vi.fn(), })); type HookHandler = (event: unknown, context: unknown) => unknown; @@ -90,8 +90,8 @@ const jobs = [ beforeEach(() => { getStewardAutomationReconciler()?.stop(); setStewardAutomationReconciler(null); - vi.mocked(submitStewardAutomationProject).mockReset(); - vi.mocked(submitStewardAutomationProject).mockResolvedValue(undefined); + vi.mocked(submitStewardAutomationProjection).mockReset(); + vi.mocked(submitStewardAutomationProjection).mockResolvedValue(undefined); }); afterEach(() => { @@ -100,15 +100,15 @@ afterEach(() => { }); describe('reconcileStewardAutomation', () => { - it('reads the complete list including disabled jobs and submits its normalized project', async () => { + 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(submitStewardAutomationProject).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenCalledWith({ + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ project: { tasks: [ { @@ -125,12 +125,12 @@ describe('reconcileStewardAutomation', () => { }); }); - it('submits an empty complete project after a successful empty read', async () => { + it('submits an empty complete projection after a successful empty read', async () => { const { context } = cronContext([]); await reconcileStewardAutomation(context.getCron); - expect(submitStewardAutomationProject).toHaveBeenCalledWith({ + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ project: { tasks: [] }, }); }); @@ -146,23 +146,23 @@ describe('reconcileStewardAutomation', () => { await expect(reconcileStewardAutomation(getCron)).rejects.toBeInstanceOf( StewardAutomationCronUnavailableError ); - expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); }); - it('propagates read failures without submitting an empty project', async () => { + 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(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); }); it('propagates submission failures for later retry', async () => { const submissionError = new Error('poke nack'); const { context } = cronContext(jobs); - vi.mocked(submitStewardAutomationProject).mockRejectedValue( + vi.mocked(submitStewardAutomationProjection).mockRejectedValue( submissionError ); @@ -197,11 +197,11 @@ describe('StewardAutomationReconciler', () => { expect(list1).toHaveBeenCalledOnce(); expect(list2).not.toHaveBeenCalled(); expect(list3).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenCalledTimes(2); - expect(submitStewardAutomationProject).toHaveBeenNthCalledWith(1, { + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenNthCalledWith(1, { project: { tasks: [expect.objectContaining({ id: 'first' })] }, }); - expect(submitStewardAutomationProject).toHaveBeenNthCalledWith(2, { + expect(submitStewardAutomationProjection).toHaveBeenNthCalledWith(2, { project: { tasks: [expect.objectContaining({ id: 'latest-follow-up' })], }, @@ -210,7 +210,7 @@ describe('StewardAutomationReconciler', () => { it('waits for submission before starting a triggered follow-up', async () => { const firstAcknowledgement = deferred(); - vi.mocked(submitStewardAutomationProject) + vi.mocked(submitStewardAutomationProjection) .mockImplementationOnce(() => firstAcknowledgement.promise.then(() => {})) .mockResolvedValueOnce(undefined); const firstContext = cronContext([job('older')]); @@ -219,23 +219,23 @@ describe('StewardAutomationReconciler', () => { const first = reconciler.start(firstContext.context.getCron); await vi.waitFor(() => { - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); }); const next = reconciler.trigger(nextContext.context.getCron); expect(nextContext.list).not.toHaveBeenCalled(); - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); firstAcknowledgement.resolve(undefined); await first; await next; expect(nextContext.list).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenCalledTimes(2); - expect(submitStewardAutomationProject).toHaveBeenNthCalledWith(1, { + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenNthCalledWith(1, { project: { tasks: [expect.objectContaining({ id: 'older' })] }, }); - expect(submitStewardAutomationProject).toHaveBeenNthCalledWith(2, { + expect(submitStewardAutomationProjection).toHaveBeenNthCalledWith(2, { project: { tasks: [expect.objectContaining({ id: 'newer' })] }, }); }); @@ -320,12 +320,12 @@ describe('StewardAutomationReconciler', () => { const repair = reconciler.trigger(recovered.context.getCron); expect(recovered.list).not.toHaveBeenCalled(); - expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); waits[0].resolve(); await Promise.all([initial, repair]); expect(recovered.list).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); } ); @@ -339,8 +339,8 @@ describe('StewardAutomationReconciler', () => { ); await reconciler.start(previous.context.getCron); - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenLastCalledWith({ + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenLastCalledWith({ project: { tasks: [expect.objectContaining({ id: 'previous' })] }, }); @@ -358,8 +358,8 @@ describe('StewardAutomationReconciler', () => { expect(startupSettled).toBe(false); expect(recoverySettled).toBe(false); expect(latest.list).not.toHaveBeenCalled(); - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenLastCalledWith({ + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenLastCalledWith({ project: { tasks: [expect.objectContaining({ id: 'previous' })] }, }); @@ -370,8 +370,8 @@ describe('StewardAutomationReconciler', () => { expect(recoverySettled).toBe(true); expect(latest.list).toHaveBeenCalledOnce(); expect(latest.list).toHaveBeenCalledWith({ includeDisabled: true }); - expect(submitStewardAutomationProject).toHaveBeenCalledTimes(2); - expect(submitStewardAutomationProject).toHaveBeenLastCalledWith({ + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenLastCalledWith({ project: { tasks: [expect.objectContaining({ id: 'latest' })] }, }); }); @@ -389,13 +389,13 @@ describe('StewardAutomationReconciler', () => { const result = reconciler.start(() => ({ list })); await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); - expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); waits[0].resolve(); await result; expect(list).toHaveBeenCalledTimes(2); - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenCalledWith({ + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ project: { tasks: [expect.objectContaining({ id: 'after-list-recovery' })], }, @@ -412,7 +412,7 @@ describe('StewardAutomationReconciler', () => { .fn() .mockResolvedValueOnce([invalid]) .mockResolvedValue([job('valid')]); - vi.mocked(submitStewardAutomationProject) + vi.mocked(submitStewardAutomationProjection) .mockRejectedValueOnce(new Error('poke nack')) .mockResolvedValueOnce(undefined); const reconciler = new StewardAutomationReconciler( @@ -422,18 +422,18 @@ describe('StewardAutomationReconciler', () => { const result = reconciler.start(() => ({ list })); await vi.waitFor(() => expect(delay).toHaveBeenCalledTimes(1)); - expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); waits[0].resolve(); await vi.waitFor(() => expect(delay).toHaveBeenCalledTimes(2)); expect(list).toHaveBeenCalledTimes(2); - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); waits[1].resolve(); await result; expect(list).toHaveBeenCalledTimes(3); - expect(submitStewardAutomationProject).toHaveBeenCalledTimes(2); - expect(submitStewardAutomationProject).toHaveBeenLastCalledWith({ + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenLastCalledWith({ project: { tasks: [expect.objectContaining({ id: 'valid' })] }, }); }); @@ -465,10 +465,10 @@ describe('StewardAutomationReconciler', () => { expect(delay).toHaveBeenCalledOnce(); expect(staleList).not.toHaveBeenCalled(); expect(latestList).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); }); - it('submits an empty project only after an actual successful empty list', async () => { + it('submits an empty projection only after an actual successful empty list', async () => { const { delay, waits } = controlledRetryDelay(); const list = vi .fn() @@ -481,12 +481,12 @@ describe('StewardAutomationReconciler', () => { const result = reconciler.start(() => ({ list })); await vi.waitFor(() => expect(delay).toHaveBeenCalledOnce()); - expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); waits[0].resolve(); await result; - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenCalledWith({ + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ project: { tasks: [] }, }); }); @@ -514,7 +514,7 @@ describe('StewardAutomationReconciler', () => { expect(reconcile).toHaveBeenCalledOnce(); expect(delay).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); }); it('stops during an outstanding list without submitting or retrying', async () => { @@ -537,7 +537,7 @@ describe('StewardAutomationReconciler', () => { await vi.waitFor(() => expect(list).toHaveBeenCalledOnce()); expect(delay).not.toHaveBeenCalled(); - expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); }); it('checks the active epoch at an injected pre-submit boundary', async () => { @@ -568,7 +568,7 @@ describe('StewardAutomationReconciler', () => { releaseBoundary.resolve(); await cancelled; - expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); }); it('clears coalesced pending triggers when the gateway stops', async () => { @@ -592,7 +592,7 @@ describe('StewardAutomationReconciler', () => { await vi.waitFor(() => expect(firstList).toHaveBeenCalledOnce()); expect(pendingList).not.toHaveBeenCalled(); - expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); }); it('restarts with one fresh snapshot and blocks the stale prior epoch', async () => { @@ -615,8 +615,8 @@ describe('StewardAutomationReconciler', () => { expect(staleList).toHaveBeenCalledOnce(); expect(freshList).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenCalledWith({ + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ project: { tasks: [expect.objectContaining({ id: 'fresh' })] }, }); }); @@ -646,11 +646,11 @@ describe('registerStewardAutomationReconciliationHooks', () => { await api.fire('gateway_start', { port: 3000 }, context); await vi.waitFor(() => { - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); }); await api.fire('gateway_stop', { reason: 'shutdown' }, context); list.mockClear(); - vi.mocked(submitStewardAutomationProject).mockClear(); + vi.mocked(submitStewardAutomationProjection).mockClear(); await api.fire( 'cron_changed', @@ -658,7 +658,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { context ); expect(list).not.toHaveBeenCalled(); - expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); }); it('reconciles after gateway_start', async () => { @@ -673,7 +673,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { await api.fire('gateway_start', { port: 3000 }, context); await vi.waitFor(() => { - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); }); expect(list).toHaveBeenCalledWith({ includeDisabled: true }); @@ -714,10 +714,10 @@ describe('registerStewardAutomationReconciliationHooks', () => { await api.fire('gateway_start', { port: 3000 }, context); await vi.waitFor(() => { - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); }); list.mockClear(); - vi.mocked(submitStewardAutomationProject).mockClear(); + vi.mocked(submitStewardAutomationProjection).mockClear(); await api.fire( 'cron_changed', @@ -734,13 +734,13 @@ describe('registerStewardAutomationReconciliationHooks', () => { context ); await vi.waitFor(() => { - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); }); expect(list).toHaveBeenCalledOnce(); expect(list).toHaveBeenCalledWith({ includeDisabled: true }); - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).toHaveBeenCalledWith({ + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledWith({ project: { tasks: [ { @@ -788,7 +788,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { expect(getStewardAutomationReconciler()).toBe(discovery); await fullApi.fire('gateway_start', { port: 3000 }, initial.context); await vi.waitFor(() => { - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); }); const prewarm = registerStewardAutomationReconciliationHooks( @@ -805,7 +805,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { changed.context ); await vi.waitFor(() => { - expect(submitStewardAutomationProject).toHaveBeenCalledTimes(2); + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(2); }); await prewarmApi.fire( @@ -824,7 +824,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { const restarted = cronContext([job('restarted')]); await discoveryApi.fire('gateway_start', { port: 3001 }, restarted.context); await vi.waitFor(() => { - expect(submitStewardAutomationProject).toHaveBeenCalledTimes(3); + expect(submitStewardAutomationProjection).toHaveBeenCalledTimes(3); }); expect(restarted.list).toHaveBeenCalledOnce(); }); @@ -850,14 +850,14 @@ describe('registerStewardAutomationReconciliationHooks', () => { await api1.fire('gateway_start', { port: 3000 }, initial.context); await vi.waitFor(() => { - expect(submitStewardAutomationProject).toHaveBeenCalledOnce(); + 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(submitStewardAutomationProject).toHaveBeenCalledOnce(); + expect(submitStewardAutomationProjection).toHaveBeenCalledOnce(); }); it('dispatches projection work without awaiting an outstanding list', async () => { @@ -881,7 +881,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { ); expect(list).toHaveBeenCalledOnce(); - expect(submitStewardAutomationProject).not.toHaveBeenCalled(); + expect(submitStewardAutomationProjection).not.toHaveBeenCalled(); await api.fire('gateway_stop', { reason: 'shutdown' }, {}); listed.resolve([]); await Promise.resolve(); diff --git a/packages/openclaw/src/steward-automation-reconciliation.ts b/packages/openclaw/src/steward-automation-reconciliation.ts index 969518e0c2..cc362e5b83 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.ts @@ -5,8 +5,8 @@ import type { } from 'openclaw/plugin-sdk/types'; import { sharedSlot } from './shared-state.js'; -import { submitStewardAutomationProject } from './steward-automation-adapter.js'; -import { normalizeStewardAutomationProject } from './steward-automation-projection.js'; +import { submitStewardAutomationProjection } from './steward-automation-adapter.js'; +import { normalizeStewardAutomationProjection } from './steward-automation-projection.js'; type StewardAutomationCronService = Pick; @@ -105,12 +105,12 @@ export async function reconcileStewardAutomation( } const jobs = await cron.list({ includeDisabled: true }); - const action = normalizeStewardAutomationProject(jobs); + const projection = normalizeStewardAutomationProjection(jobs); await beforeSubmit?.(); // Keep this synchronous check adjacent to invoking the adapter. Awaiting a // lifecycle guard here would reopen a microtask-sized stale-submit race. assertCanSubmit?.(); - await submitStewardAutomationProject(action); + await submitStewardAutomationProjection(projection); } /** From 46b44b1051f13276f5cce7f3e212f63e8cc8af1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Thu, 13 Aug 2026 08:01:48 +0800 Subject: [PATCH 59/62] openspec: record payload field validation --- .../changes/mirror-openclaw-automations-to-steward/tasks.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 73973d8a76..39fff533d6 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -116,7 +116,9 @@ ISO datetime validation and focused projection tests. - [x] 5.12 Replace the remaining projection field helpers with complete Zod task, schedule, and payload schemas. -- [ ] 5.13 Make OpenClaw `message` the canonical agent-turn payload +- [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. +- [ ] 5.15 Simplify the Steward automation TypeScript without + weakening validation, lifecycle, or race guarantees. From 2b301f254ff6d569e72de9831817601b566fa140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Thu, 13 Aug 2026 08:02:12 +0800 Subject: [PATCH 60/62] openclaw: simplify steward automation code --- .../tasks.md | 2 +- .../src/steward-automation-adapter.test.ts | 5 -- .../src/steward-automation-projection.test.ts | 21 ++++- .../src/steward-automation-projection.ts | 82 ++++++------------- .../steward-automation-reconciliation.test.ts | 80 ++++-------------- .../src/steward-automation-reconciliation.ts | 29 +++---- 6 files changed, 74 insertions(+), 145 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 39fff533d6..b61ef2deaf 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -120,5 +120,5 @@ field, retaining `text` only as a compatibility fallback. - [x] 5.14 Rename TypeScript `Project` terminology to `Projection` while preserving the `%project` wire action. -- [ ] 5.15 Simplify the Steward automation TypeScript without +- [x] 5.15 Simplify the Steward automation TypeScript without weakening validation, lifecycle, or race guarantees. diff --git a/packages/openclaw/src/steward-automation-adapter.test.ts b/packages/openclaw/src/steward-automation-adapter.test.ts index 294d38decf..6c5d6fc815 100644 --- a/packages/openclaw/src/steward-automation-adapter.test.ts +++ b/packages/openclaw/src/steward-automation-adapter.test.ts @@ -117,10 +117,5 @@ describe('submitStewardAutomationProjection', () => { expect(stalePoke).toHaveBeenCalledOnce(); expect(currentPoke).toHaveBeenCalledOnce(); - expect(currentPoke).toHaveBeenCalledWith({ - 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 index 1d4723d456..b48bb94b7f 100644 --- a/packages/openclaw/src/steward-automation-projection.test.ts +++ b/packages/openclaw/src/steward-automation-projection.test.ts @@ -250,9 +250,24 @@ describe('Steward automation projection normalization', () => { /cron job bad-at schedule\.at: expected an ISO timestamp/, ], [ - 'invalid number', - { id: 'bad-every', schedule: { kind: 'every', everyMs: -1 } }, - /expected a non-negative safe integer/, + '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', diff --git a/packages/openclaw/src/steward-automation-projection.ts b/packages/openclaw/src/steward-automation-projection.ts index a072011eef..2f52d6cec5 100644 --- a/packages/openclaw/src/steward-automation-projection.ts +++ b/packages/openclaw/src/steward-automation-projection.ts @@ -1,64 +1,27 @@ import type { PluginHookGatewayCronJob } from 'openclaw/plugin-sdk/types'; import { z } from 'zod'; -export type StewardAutomationSchedule = - | { - kind: 'cron'; - expr?: string; - tz?: string; - staggerMs?: number; - } - | { - kind: 'at'; - at?: number; - } - | { - kind: 'every'; - everyMs?: number; - anchorMs?: number; - }; - -export interface StewardAutomationPayload { - kind?: string; - message?: string; -} - -export interface StewardAutomationTask { - id: string; - agentId?: string; - name?: string; - description?: string; - enabled?: boolean; - schedule?: StewardAutomationSchedule; - sessionTarget?: string; - wakeMode?: string; - payload?: StewardAutomationPayload; - createdAtMs?: number; - updatedAtMs?: number; -} - -export interface StewardAutomationProjection { - project: { - tasks: StewardAutomationTask[]; - }; -} +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 ExpectedBooleanSchema = z.boolean({ error: 'expected a boolean' }); const NaturalNumberSchema = z - .number({ error: 'expected a non-negative safe integer' }) - .int({ error: 'expected a non-negative safe integer' }) - .safe({ error: 'expected a non-negative safe integer' }) - .nonnegative({ error: 'expected a non-negative safe integer' }); + .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 an ISO timestamp' }) + .datetime({ offset: true, error: EXPECTED_ISO_TIMESTAMP }) .transform(Date.parse) .pipe( z - .number({ error: 'expected an ISO timestamp' }) - .int({ error: 'expected an ISO timestamp' }) - .safe({ error: 'expected an ISO timestamp' }) - .nonnegative({ error: 'expected an ISO timestamp' }) + .int({ error: EXPECTED_ISO_TIMESTAMP }) + .nonnegative({ error: EXPECTED_ISO_TIMESTAMP }) ); const PayloadSchema = z @@ -74,7 +37,7 @@ const PayloadSchema = z return { ...(kind === undefined ? {} : { kind }), ...(message === undefined ? {} : { message }), - } satisfies StewardAutomationPayload; + }; }); const CronScheduleSchema = z @@ -125,7 +88,7 @@ const CronJobSchema = z agentId: ExpectedStringSchema.optional(), name: ExpectedStringSchema.optional(), description: ExpectedStringSchema.optional(), - enabled: ExpectedBooleanSchema.optional(), + enabled: z.boolean({ error: 'expected a boolean' }).optional(), schedule: ScheduleSchema.optional(), sessionTarget: ExpectedStringSchema.optional(), wakeMode: ExpectedStringSchema.optional(), @@ -161,7 +124,15 @@ const CronJobSchema = z }) ); -type NormalizedCronJob = z.infer; +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({ @@ -200,8 +171,7 @@ function normalizeTask(job: PluginHookGatewayCronJob): StewardAutomationTask { if (!parsed.success) { throw formatCronJobError(parsed.error, job); } - const task: NormalizedCronJob = parsed.data; - return task; + return parsed.data; } /** Normalize one complete OpenClaw cron list into Steward's `%project` JSON. */ diff --git a/packages/openclaw/src/steward-automation-reconciliation.test.ts b/packages/openclaw/src/steward-automation-reconciliation.test.ts index 6671da83bc..36adeec022 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.test.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -1,3 +1,4 @@ +import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core'; import type { PluginHookCronChangedEvent, PluginHookGatewayContext, @@ -22,10 +23,13 @@ vi.mock('./steward-automation-adapter.js', () => ({ })); type HookHandler = (event: unknown, context: unknown) => unknown; +type FakeHookApi = Pick & { + fire: (name: string, event: unknown, context: unknown) => Promise; +}; -function createFakeHookApi() { +function createFakeHookApi(): FakeHookApi { const handlers = new Map(); - return { + const api = { on: vi.fn((name: string, handler: HookHandler) => { handlers.set(name, [...(handlers.get(name) ?? []), handler]); }), @@ -35,6 +39,7 @@ function createFakeHookApi() { } }, }; + return api as unknown as FakeHookApi; } function cronContext(jobs: PluginHookGatewayCronJob[]) { @@ -629,12 +634,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { it('registers gateway_stop and ignores cron changes while inactive', async () => { const api = createFakeHookApi(); const { context, list } = cronContext(jobs); - registerStewardAutomationReconciliationHooks( - api as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], - registrationOptions() - ); + registerStewardAutomationReconciliationHooks(api, registrationOptions()); expect(api.on).toHaveBeenCalledWith('gateway_stop', expect.any(Function)); await api.fire( @@ -664,12 +664,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { it('reconciles after gateway_start', async () => { const api = createFakeHookApi(); const { context, list } = cronContext(jobs); - registerStewardAutomationReconciliationHooks( - api as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], - registrationOptions() - ); + registerStewardAutomationReconciliationHooks(api, registrationOptions()); await api.fire('gateway_start', { port: 3000 }, context); await vi.waitFor(() => { @@ -705,12 +700,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { }, ] satisfies PluginHookGatewayCronJob[]; const { context, list } = cronContext(completeJobs); - registerStewardAutomationReconciliationHooks( - api as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], - registrationOptions() - ); + registerStewardAutomationReconciliationHooks(api, registrationOptions()); await api.fire('gateway_start', { port: 3000 }, context); await vi.waitFor(() => { @@ -771,17 +761,10 @@ describe('registerStewardAutomationReconciliationHooks', () => { const prewarmApi = createFakeHookApi(); const options = registrationOptions(); const discovery = registerStewardAutomationReconciliationHooks( - discoveryApi as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], - options - ); - const full = registerStewardAutomationReconciliationHooks( - fullApi as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], + discoveryApi, options ); + const full = registerStewardAutomationReconciliationHooks(fullApi, options); const initial = cronContext([job('initial')]); expect(full).toBe(discovery); @@ -792,9 +775,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { }); const prewarm = registerStewardAutomationReconciliationHooks( - prewarmApi as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], + prewarmApi, options ); const changed = cronContext([job('changed')]); @@ -833,18 +814,8 @@ describe('registerStewardAutomationReconciliationHooks', () => { const api1 = createFakeHookApi(); const api2 = createFakeHookApi(); const options = registrationOptions(); - registerStewardAutomationReconciliationHooks( - api1 as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], - options - ); - registerStewardAutomationReconciliationHooks( - api2 as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], - options - ); + registerStewardAutomationReconciliationHooks(api1, options); + registerStewardAutomationReconciliationHooks(api2, options); const initial = cronContext([job('initial')]); const duplicate = cronContext([job('duplicate')]); @@ -865,12 +836,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { const options = registrationOptions(); const listed = deferred(); const list = vi.fn(() => listed.promise); - registerStewardAutomationReconciliationHooks( - api as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], - options - ); + registerStewardAutomationReconciliationHooks(api, options); await api.fire( 'gateway_start', @@ -899,12 +865,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { } as unknown as StewardAutomationReconciler; setStewardAutomationReconciler(injected); const telemetry = vi.fn(); - registerStewardAutomationReconciliationHooks( - api as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], - options - ); + registerStewardAutomationReconciliationHooks(api, options); api.on('gateway_start', telemetry); await api.fire('gateway_start', { port: 3000 }, { getCron: undefined }); @@ -930,12 +891,7 @@ describe('registerStewardAutomationReconciliationHooks', () => { throw new Error('logger unavailable'); }); setStewardAutomationReconciler(injected); - registerStewardAutomationReconciliationHooks( - api as unknown as Parameters< - typeof registerStewardAutomationReconciliationHooks - >[0], - { logger: { warn } } - ); + registerStewardAutomationReconciliationHooks(api, { logger: { warn } }); 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 index cc362e5b83..d47772966c 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.ts @@ -1,8 +1,5 @@ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core'; -import type { - PluginHookGatewayContext, - PluginHookGatewayCronService, -} from 'openclaw/plugin-sdk/types'; +import type { PluginHookGatewayCronService } from 'openclaw/plugin-sdk/types'; import { sharedSlot } from './shared-state.js'; import { submitStewardAutomationProjection } from './steward-automation-adapter.js'; @@ -160,7 +157,7 @@ export class StewardAutomationReconciler { } stop(): void { - this.deactivate('gateway-stop'); + this.deactivate(); } private enqueue( @@ -190,7 +187,7 @@ export class StewardAutomationReconciler { return promise; } - private deactivate(reason: 'gateway-stop' | 'gateway-restart'): void { + private deactivate(): void { const epoch = this.activeEpoch; if (epoch === null) { return; @@ -198,7 +195,7 @@ export class StewardAutomationReconciler { const cancellation = new StewardAutomationReconciliationCancelledError( epoch, - reason + 'gateway-stop' ); this.activeEpoch = null; this.retryController?.abort(cancellation); @@ -394,21 +391,17 @@ export function registerStewardAutomationReconciliationHooks( api: Pick, options: RegisterStewardAutomationReconciliationHooksOptions ): StewardAutomationReconciler { - const reconciler = - getStewardAutomationReconciler() ?? - (() => { - const created = new StewardAutomationReconciler(); - setStewardAutomationReconciler(created); - return created; - })(); - const getCron = (ctx: Pick) => - ctx.getCron; + let reconciler = getStewardAutomationReconciler(); + if (!reconciler) { + reconciler = new StewardAutomationReconciler(); + setStewardAutomationReconciler(reconciler); + } api.on('gateway_start', (_event, ctx) => { - observeProjectionWork(reconciler.start(getCron(ctx)), options.logger); + observeProjectionWork(reconciler.start(ctx.getCron), options.logger); }); api.on('cron_changed', (_event, ctx) => { - observeProjectionWork(reconciler.trigger(getCron(ctx)), options.logger); + observeProjectionWork(reconciler.trigger(ctx.getCron), options.logger); }); api.on('gateway_stop', () => { reconciler.stop(); From 24a50cbcf26f2c07d0fb46895ddc0ad1ed2582d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Thu, 13 Aug 2026 16:39:02 +0800 Subject: [PATCH 61/62] openclaw: inline steward reconciliation stop --- .../tasks.md | 2 + .../src/steward-automation-reconciliation.ts | 48 +++++++++---------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index b61ef2deaf..75cc3548cf 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -122,3 +122,5 @@ 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()`. diff --git a/packages/openclaw/src/steward-automation-reconciliation.ts b/packages/openclaw/src/steward-automation-reconciliation.ts index d47772966c..46e2517f14 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.ts @@ -157,7 +157,28 @@ export class StewardAutomationReconciler { } stop(): void { - this.deactivate(); + const epoch = this.activeEpoch; + if (epoch === null) { + return; + } + + const cancellation = new StewardAutomationReconciliationCancelledError( + epoch, + 'gateway-stop' + ); + this.activeEpoch = null; + this.retryController?.abort(cancellation); + this.retryController = 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( @@ -187,31 +208,6 @@ export class StewardAutomationReconciler { return promise; } - private deactivate(): void { - const epoch = this.activeEpoch; - if (epoch === null) { - return; - } - - const cancellation = new StewardAutomationReconciliationCancelledError( - epoch, - 'gateway-stop' - ); - this.activeEpoch = null; - this.retryController?.abort(cancellation); - this.retryController = 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 async drain(): Promise { try { for (;;) { From 451b16923fbe40ef02ceaeccf440cec86f6bdb2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Paraniak?= Date: Fri, 14 Aug 2026 17:53:11 +0800 Subject: [PATCH 62/62] openclaw: prevent hung automation reconciliation --- docs/backend/desk/app/steward.md | 4 +- .../tasks.md | 3 + .../steward-automation-reconciliation.test.ts | 120 +++++++++++++ .../src/steward-automation-reconciliation.ts | 166 +++++++++++++++--- 4 files changed, 269 insertions(+), 24 deletions(-) diff --git a/docs/backend/desk/app/steward.md b/docs/backend/desk/app/steward.md index ab8e09b312..9f42415470 100644 --- a/docs/backend/desk/app/steward.md +++ b/docs/backend/desk/app/steward.md @@ -144,9 +144,9 @@ Automation intentionally has no mutation or owner administration surface and no 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. -Reconciliation is serialized so an older snapshot cannot overtake a newer one. 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. Until a later operation succeeds, the ship retains its last successful projection. +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, 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. 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. +`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. diff --git a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md index 75cc3548cf..ed39334c3c 100644 --- a/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md +++ b/openspec/changes/mirror-openclaw-automations-to-steward/tasks.md @@ -124,3 +124,6 @@ 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. diff --git a/packages/openclaw/src/steward-automation-reconciliation.test.ts b/packages/openclaw/src/steward-automation-reconciliation.test.ts index 36adeec022..d5389a2999 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.test.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.test.ts @@ -496,6 +496,70 @@ describe('StewardAutomationReconciler', () => { }); }); + 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) => { @@ -600,6 +664,62 @@ describe('StewardAutomationReconciler', () => { 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); diff --git a/packages/openclaw/src/steward-automation-reconciliation.ts b/packages/openclaw/src/steward-automation-reconciliation.ts index 46e2517f14..8bc615cf0e 100644 --- a/packages/openclaw/src/steward-automation-reconciliation.ts +++ b/packages/openclaw/src/steward-automation-reconciliation.ts @@ -16,7 +16,8 @@ type StewardAutomationSubmissionGuard = () => void | Promise; type StewardAutomationReconciliation = ( getCron: StewardAutomationCronAccessor, beforeSubmit?: StewardAutomationSubmissionGuard, - assertCanSubmit?: () => void + assertCanSubmit?: () => void, + signal?: AbortSignal ) => Promise; interface ReconciliationWaiter { @@ -26,12 +27,97 @@ interface ReconciliationWaiter { 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, @@ -90,8 +176,10 @@ export class StewardAutomationReconciliationCancelledError extends Error { export async function reconcileStewardAutomation( getCron: StewardAutomationCronAccessor, beforeSubmit?: StewardAutomationSubmissionGuard, - assertCanSubmit?: () => void + assertCanSubmit?: () => void, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted(); if (!getCron) { throw new StewardAutomationCronUnavailableError('missing-accessor'); } @@ -102,10 +190,12 @@ export async function reconcileStewardAutomation( } const jobs = await cron.list({ includeDisabled: true }); + signal?.throwIfAborted(); const projection = normalizeStewardAutomationProjection(jobs); await beforeSubmit?.(); - // Keep this synchronous check adjacent to invoking the adapter. Awaiting a - // lifecycle guard here would reopen a microtask-sized stale-submit race. + 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); } @@ -115,10 +205,12 @@ export async function reconcileStewardAutomation( * * `start` creates an epoch and requests its full snapshot, while duplicate * starts during that epoch are ignored. Active triggers are coalesced. `stop` - * cancels retry delay, rejects + * cancels retry delays and abandons in-flight operation waits, rejects * outstanding promises with a typed cancellation, and leaves durable Steward - * state untouched. A stopped reconciler ignores later change triggers until a - * new `start` creates a fresh epoch. + * 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; @@ -126,12 +218,13 @@ export class StewardAutomationReconciler { private running = false; private epoch = 0; private activeEpoch: number | null = null; - private retryController: AbortController | 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 retryDelayMs = DEFAULT_STEWARD_AUTOMATION_RETRY_DELAY_MS, + private readonly operationTimeoutMs = DEFAULT_STEWARD_AUTOMATION_OPERATION_TIMEOUT_MS ) {} start(getCron: StewardAutomationCronAccessor): Promise { @@ -143,17 +236,18 @@ export class StewardAutomationReconciler { } const epoch = ++this.epoch; + const controller = new AbortController(); this.activeEpoch = epoch; - this.retryController = new AbortController(); - return this.enqueue(epoch, getCron); + 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) { + if (this.activeEpoch === null || this.activeController === null) { return Promise.resolve(); } - return this.enqueue(this.activeEpoch, getCron); + return this.enqueue(this.activeEpoch, this.activeController, getCron); } stop(): void { @@ -167,8 +261,8 @@ export class StewardAutomationReconciler { 'gateway-stop' ); this.activeEpoch = null; - this.retryController?.abort(cancellation); - this.retryController = null; + this.activeController?.abort(cancellation); + this.activeController = null; if (this.current?.epoch === epoch) { this.rejectBatch(this.current, cancellation); @@ -183,16 +277,31 @@ export class StewardAutomationReconciler { private enqueue( epoch: number, + controller: AbortController, getCron: StewardAutomationCronAccessor ): Promise { const promise = new Promise((resolve, reject) => { const waiter = { resolve, reject }; - if (this.pending?.epoch === epoch) { + + 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, @@ -224,8 +333,24 @@ export class StewardAutomationReconciler { } try { - await this.reconcile(batch.getCron, undefined, () => - this.assertActiveEpoch(batch.epoch) + 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); @@ -237,10 +362,7 @@ export class StewardAutomationReconciler { } try { - await this.retryDelay( - this.retryDelayMs, - this.retryController?.signal - ); + await this.retryDelay(this.retryDelayMs, batch.controller.signal); } catch (delayError) { this.rejectBatch(batch, delayError); break;