fix(runtime,cloud-connection): an install-local package binds its script-action bodies and body hooks; list_actions lists only what run_action can run - #21401
Conversation
…tion bodies and body hooks, called by AppPlugin and by install-local; list_actions reads the handler registry Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…e; install-local suites pay the runtime load at module top Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…s the handler registry), cloud-connection patch Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…stall-local-script-actions Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
… signs in as the dev admin, and attributes every exchange to the child Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 92517b0c59685edf283df5aacac52f41c0d031c2 && git checkout 92517b0c59685edf283df5aacac52f41c0d031c2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ecb6ca0258176466767588a6805363387c5777a6 2e4e1ff4f9f4f6303daee6d24f91f20bca2bc7dc && git checkout -B drift-repro ecb6ca0258176466767588a6805363387c5777a6 && git merge --no-ff 2e4e1ff4f9f4f6303daee6d24f91f20bca2bc7dc
node scripts/docs-audit/affected-docs.mjs --json ecb6ca0258176466767588a6805363387c5777a6
|
Contract reviewServed-tier: Head fetched into ① Derived judgments(a) Route A — right.
(b) Lazy load with a warn — right: an acceptable version-skew guard, not the forbidden degradation.
(c) Probe A — right; the key walk matches the run door exactly.
(d) Hook half — right. (e) Pins and the out-of-surface test edits — right; no assertion weakened.
(f) Changeset and PR prose — each factual sentence true at the head: the export names; "same log lines and the same results for a boot artifact"; "An engine without ② Semver levelRight. ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #21321
Clause-②: yes (widening)
An app installed with
os package install ./dist/objectstack.json(install-local) now runs itstype: 'script'action bodies and its body hooks exactly as the same artifact does underos start --artifact, on install, after a reinstall and after a restart. MCPlist_actionsnow lists a script action only whenrun_actioncan run it. Triage's rulings on the card are implemented as written: route A, probe A, and the hook half folded in.What changed
@objectstack/runtime. The new modulepackages/runtime/src/app-artifact-handlers.tsexportsbindAppArtifactHandlers(ql, bundle, { appId, logger, source })andappArtifactHandlerOwner(appId), and the package index re-exports both. The function binds an artifact's action bodies throughql.registerActionand its hook bodies and bundle functions throughql.bindHooks, all under the ownerapp:APPID. It first removes the action handlers and hooks that owner bound before. On a first bind this does nothing. On a reinstall it keeps one handler per action and one binding per hook, and it unbinds an action or hook the new version dropped. The hook removal is explicit becausebindHooksToEngineunregisters only when it is given a non-empty list.AppPlugin.startcalls the binder in place of its two inline blocks. The order (afterruntime.onEnable), the log lines and the failure handling are the same as before.@objectstack/cloud-connection. The plugin calls the binder onPOST /api/v1/marketplace/install-local, aftermanifest.registerandsyncSchemasand before translations and seeds. It calls it again on thekernel:readyrehydrate of each ledger entry, with appId set to the manifest id. The runtime is loaded lazily, like the plugin's other runtime helpers. A runtime without the export binds nothing and logs awarnthat names the consequence. There is no fallback registration path.list_actions.registeredActionHandlerProbesits besideexecuteRegisteredActioninaction-execution.ts. It reads the engine's publiclistRegisteredActions()once per listing and walks the sameactionHandlerObjectKeysxresolveActionHandlerKeysorder as the run door.list_actionsuses it for the script branch: every actioninvokeBusinessActionsends to the handler registry, meaning neither a declarative update nor a flow. Those two branches keep their own checks. An engine withoutlistRegisteredActionslists no script action. No enginehasActionwas added.packages/objectql,packages/metadata-protocolorpackages/spec.Measured with the real CLI, before and after
The app has one object, one script action with an inline body and
ai.exposed, and onebeforeInsertbody hook that appendsstampedtostatus. The flow isos build, then an emptyos start(OS_CLOUD_URL=off), thenos package install ./dist/objectstack.json. Probes went over REST and over MCP Streamable HTTP with a minted API key.status: nullstatus: "stamped"POST /api/v1/actions/tasks_app_task/complete_taskRESOURCE_NOT_FOUND{ok:true}, rowdonerun_action{ok:true, result:{ok:true}}, rowdonelist_actionscomplete_task(that run_action then refuses)complete_task(that run_action runs)stamped)status: null; restart logre-synced runtime-authored actions {"registered":0,...}stamped; restart log[MarketplaceInstallLocal] Bound declarative actions {"appId":"com.example.tasksapp","actionCount":2}os start --artifactstampedstampedPins (each measured red on unfixed code first)
packages/cli/test/package-install-local-handlers.integration.test.ts(integration tier, spawn) runs the realos startandos package installthrough the tsx source entry, across install, reinstall, restart and the--artifactcontrol. Each phase checks four things: the hook fires once, the REST action runs, MCPrun_actionruns andlist_actionslists the action, and a declared AI-exposedghost_task(atargetnothing registers) is not listed whilerun_actionrefuses it. Red on main: 13 failed, 4 passed (the control's three rows and harness health).packages/cloud-connection/src/marketplace-install-local-artifact-handlers.test.tsuses the real runtime binder and a recording engine. It covers install, rehydrate, a reinstall leaving exactly one handler and binding, and a reinstall of a version without the action and hook unbinding both. Red on main: 4 of 4.packages/runtime/src/mcp-list-actions-handler-probe.test.tscovers listing against run_action on one engine double: an unbound body action, a target-bound key, the object-less key, the flow control, and an engine that cannot list its handlers. Red on main: 3 failed, 3 passed (the controls).packages/runtime/src/app-artifact-handlers.test.tsruns the binder on a realObjectQLengine with the QuickJS sandbox:executeActionruns the body,triggerHooksruns the hook, re-binding keeps exactly one of each, a dropped action or hook is unbound, and other owners are left alone.Ablations (fix committed at 2e4e1ff; every leg through
scripts/ablation-replace.mjs, restore proven blob == HEAD andgit status --porcelainempty; dist legs rebuilt and checked withscripts/ablation-dist-preflight.mjsboth ways)ql.removeActionsByPackage(owner)tovoid 0ql.unregisterHooksByPackage(owner)tovoid 0packageId: ownertopackageId: undefinedlist_actionsghost_tasklisted in every phase)void bindAppArtifactHandlers;A first run of leg F deleted the call outright. That left the import unused, so the runtime DTS step failed with TS6133 after the JS had already been emitted. It was re-run with the type-clean replacement in the table, and that run is the one quoted.
Tests and gates (at 2e4e1ff; main merged at db0cf22)
@objectstack/runtimevitest run --project local(2 shards): 305 files, 4341 passed, 11 skipped.@objectstack/cloud-connectionfull: 31 files, 401 passed.@objectstack/cli--project unit(3 shards): 246 files, 3489 passed. Integration project: only the new file was run locally (17 passed). The rest of the integration project is left to CI.typecheckof runtime, cloud-connection and cli: exit 0, with eachcheck:test-typecheckOK.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 66 gate commands. All 66 were run with their exit codes recorded and all exited 0.--ranreconciled: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint(the fulleslint . --no-inline-config): exit 0.Acceptance notes
AppPluginnow clearsapp:APPIDbefore it binds. A first boot is unchanged. If twoAppPlugins on one engine share an app id, the later one's set now replaces the earlier one's actions as well; before, it replaced only the hooks.bundle,conflict,id-gate,list-posture,offline-degradation,posture-gate,storage-dir) gained a module-topimport '@objectstack/runtime'. An install or rehydrate now reaches the runtime's lazy import, and its first load inside a 5000msittimed outposture-gateandid-gate(the clocked-window rule inscripts/check-test-source-alias.mjs). The suite now pays that load during collection. No baseline duration was measured.list_actionsengine fixtures inhttp-dispatcher.test.tsgainedlistRegisteredActions(), listing the keys theirexecuteActionalready answers.bin/run.jsentry would add the file tocheck:cli-test-child-env's pinned roster of six built-entry spawners, which needs an edit to that gate.[AppPlugin|MarketplaceInstallLocal] Bound declarative actionscount is registrations, not distinct handlers. The same action collected fromactions[]andobjects[].actions[]counts 2 for one handler. This is unchanged and noted only.bindAppArtifactHandlers.Generated by Claude Code