From 10bc22b0d128af9f31accb4f7242ecb42bd3cf41 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:18:43 +0000 Subject: [PATCH 1/2] fix(start-sdk): let a service pass prefill to effects.action.getInput StartOS accepts a prefill on the effect (GetActionInputParams carries it, and the container runtime forwards the options object whole), but the hand-written Effects type omitted it. A service reading another package's action form for a particular target, such as Tor's Add Onion Service for one of its own interfaces, could not say which target without a cast. startosTypeValidation did not catch it: it checks that each generated params type is assignable to the Effects parameter, and a binding with an extra field still is. Helix-Harness: claude-code Helix-Model: claude-opus-5-5 --- projects/start-sdk/CHANGELOG.md | 4 ++++ shared-libs/ts-modules/start-core/lib/Effects.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/projects/start-sdk/CHANGELOG.md b/projects/start-sdk/CHANGELOG.md index e5b2e74e96..c12868819f 100644 --- a/projects/start-sdk/CHANGELOG.md +++ b/projects/start-sdk/CHANGELOG.md @@ -152,6 +152,10 @@ ### Fixed +- **`effects.action.getInput` accepts `prefill`**, so a service can open + another package's action form for a particular target — the interface an + add-address action works on, for instance — the way the user's form is opened + - **Lazy subcontainers retry filesystem materialization after a transient failure**, allowing daemons to recover without a service restart diff --git a/shared-libs/ts-modules/start-core/lib/Effects.ts b/shared-libs/ts-modules/start-core/lib/Effects.ts index c05c66bd08..d9c6291d6c 100644 --- a/shared-libs/ts-modules/start-core/lib/Effects.ts +++ b/shared-libs/ts-modules/start-core/lib/Effects.ts @@ -54,6 +54,8 @@ export type Effects = { getInput(options: { packageId?: PackageId actionId: ActionId + /** Seeds the form, including the values its dynamic fields are computed from. */ + prefill?: Record | null }): Promise run>(options: { packageId?: PackageId From b3da16d2378b45b0087f3110397c66be4c756242 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:06:30 +0000 Subject: [PATCH 2/2] feat(start-sdk,start-os)!: run an action with input by answering the form it opened A service could not run an action that takes input, its own or another's. The SDK's run validates against the form getInput built under the same event id, and each effect call runs under a fresh procedure id, so the run found no form and threw "getActionInput has not been called". effects.action.run now takes the eventId getInput returned and runs under it, the handshake `start-cli package action run --event-id` already uses. sdk.action.run is built on it: `input` becomes a function from the opened form to the input, and the helper opens the form, applies the function and runs under that form's event id. It also gains `packageId`, to run another service's action that `access` admits, and `prefill`. A plain `input` value is no longer accepted; it could not reach a form. The binding and the start-container man page are regenerated. The actions page gains a section on running another service's action, and the unreleased 3.0.0 changelog entry replaces the prefill-only one. Helix-Harness: claude-code Helix-Model: claude-opus-5-5 --- .../start-os/man/start-container-action-run.1 | 5 +- projects/start-sdk/CHANGELOG.md | 12 +- projects/start-sdk/docs/src/actions.md | 17 ++- .../start-core/src/service/effects/action.rs | 6 + .../ts-modules/start-core/lib/Effects.ts | 3 + .../start-core/lib/actions/index.ts | 65 ++++----- .../lib/osBindings/EffectsRunActionParams.ts | 5 + .../start-core/lib/test/runAction.test.ts | 126 ++++++++++++++++++ 8 files changed, 201 insertions(+), 38 deletions(-) create mode 100644 shared-libs/ts-modules/start-core/lib/test/runAction.test.ts diff --git a/projects/start-os/man/start-container-action-run.1 b/projects/start-os/man/start-container-action-run.1 index ea89416467..ea7178085a 100644 --- a/projects/start-os/man/start-container-action-run.1 +++ b/projects/start-os/man/start-container-action-run.1 @@ -4,13 +4,16 @@ .SH NAME start\-container\-action\-run .SH SYNOPSIS -\fBstart\-container action run\fR [\fB\-p\fR|\fB\-\-package\-id\fR] [\fB\-\-format\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIACTION_ID\fR> <\fIINPUT\fR> +\fBstart\-container action run\fR [\fB\-p\fR|\fB\-\-package\-id\fR] [\fB\-\-event\-id\fR] [\fB\-\-format\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIACTION_ID\fR> <\fIINPUT\fR> .SH DESCRIPTION .SH OPTIONS .TP \fB\-p\fR, \fB\-\-package\-id\fR \fI\fR Package identifier .TP +\fB\-\-event\-id\fR \fI\fR +Unique event identifier +.TP \fB\-\-format\fR .TP diff --git a/projects/start-sdk/CHANGELOG.md b/projects/start-sdk/CHANGELOG.md index c12868819f..e39fbcf7c8 100644 --- a/projects/start-sdk/CHANGELOG.md +++ b/projects/start-sdk/CHANGELOG.md @@ -4,6 +4,14 @@ ### Changed +- **Breaking — `sdk.action.run` opens the action's form and passes it to + `input`.** `input` is a function from the opened form to the input to submit; + a plain value is no longer accepted. The run then answers that form, which is + what lets a service run an action that takes input — another service's that + `access` admits, via the new `packageId`, or its own. `prefill` seeds the + form. Underneath, `effects.action.getInput` accepts `prefill` and + `effects.action.run` accepts the `eventId` the form was opened under + - **Breaking — a filled address lists the server's `.local` name whenever the user has it enabled, and `utils.mdnsResolvable` is removed.** `.local` was left out while no LAN IP on its gateways was enabled, which dropped it @@ -152,10 +160,6 @@ ### Fixed -- **`effects.action.getInput` accepts `prefill`**, so a service can open - another package's action form for a particular target — the interface an - add-address action works on, for instance — the way the user's form is opened - - **Lazy subcontainers retry filesystem materialization after a transient failure**, allowing daemons to recover without a service restart diff --git a/projects/start-sdk/docs/src/actions.md b/projects/start-sdk/docs/src/actions.md index ac42309be7..4fb6abadd9 100644 --- a/projects/start-sdk/docs/src/actions.md +++ b/projects/start-sdk/docs/src/actions.md @@ -72,7 +72,7 @@ The action is paired with a `setupOnInit` watcher that surfaces a critical task ### Controlling Access -The optional **`access`** field on the metadata controls who may invoke the action **directly** via `effects.action.run({ packageId, actionId, input })`: +The optional **`access`** field on the metadata controls who may invoke the action **directly**, with `sdk.action.run` (see [Running Another Service's Action](#running-another-services-action)): - `'user'` (default) — only the user; another service must request it through a task (`effects.action.createTask(...)`). - `'dependent'` — only services that declare this package as a current dependency. @@ -111,6 +111,21 @@ export const registerEndpoint = sdk.Action.withInput( ) ``` +### Running Another Service's Action + +`sdk.action.run` runs one of this service's own actions, or another service's that its `access` admits. An action that takes input is run the way the user runs it: its form is opened first, and the input is checked against that form. So `input` is a function. It receives the opened form — its `spec`, and the `value` the action's prefill function supplied — and returns the input to submit. `prefill` seeds the form, including the values its dynamic fields are computed from. + +```typescript +await sdk.action.run({ + effects, + packageId: 'directory', + actionId: 'register-endpoint', + input: ({ value }) => ({ ...value, hostId: 'api' }), +}) +``` + +An action without input takes no `input`, and runs without a form. Calling the effects directly works the same way: `effects.action.run` answers a form only when it carries the `eventId` that `effects.action.getInput` returned, and an action with input refuses a run that names none. + ## Registering Actions All actions must be registered in `actions/index.ts`: diff --git a/shared-libs/crates/start-core/src/service/effects/action.rs b/shared-libs/crates/start-core/src/service/effects/action.rs index 849061de78..641c012cf8 100644 --- a/shared-libs/crates/start-core/src/service/effects/action.rs +++ b/shared-libs/crates/start-core/src/service/effects/action.rs @@ -173,6 +173,10 @@ pub struct RunActionParams { #[ts(optional)] #[arg(short, long, help = "help.arg.package-id")] package_id: Option, + /// The `eventId` from the `get-input` whose form this input answers. + #[ts(optional)] + #[arg(long, help = "help.arg.event-id")] + event_id: Option, #[arg(help = "help.arg.action-id")] action_id: ActionId, #[ts(type = "any")] @@ -184,11 +188,13 @@ async fn run_action( RunActionParams { procedure_id, package_id, + event_id, action_id, input, }: RunActionParams, ) -> Result, Error> { let context = context.deref()?; + let procedure_id = event_id.unwrap_or(procedure_id); let caller = Some(context.seed.id.clone()); let package_id = package_id.as_ref().unwrap_or(&context.seed.id); diff --git a/shared-libs/ts-modules/start-core/lib/Effects.ts b/shared-libs/ts-modules/start-core/lib/Effects.ts index d9c6291d6c..19ff1633c3 100644 --- a/shared-libs/ts-modules/start-core/lib/Effects.ts +++ b/shared-libs/ts-modules/start-core/lib/Effects.ts @@ -23,6 +23,7 @@ import { Manifest, HostnameInfo, Progress, + Guid, } from './osBindings' import { PackageId, @@ -60,6 +61,8 @@ export type Effects = { run>(options: { packageId?: PackageId actionId: ActionId + /** The `eventId` from the `getInput` whose form this input answers. Required by an action with input. */ + eventId?: Guid input?: Input }): Promise createTask(options: CreateTaskParams): Promise diff --git a/shared-libs/ts-modules/start-core/lib/actions/index.ts b/shared-libs/ts-modules/start-core/lib/actions/index.ts index 86dc221c9a..21f8e23de6 100644 --- a/shared-libs/ts-modules/start-core/lib/actions/index.ts +++ b/shared-libs/ts-modules/start-core/lib/actions/index.ts @@ -3,47 +3,48 @@ import * as IST from '../actions/input/inputSpecTypes' import { Action, ActionInfo } from './setupActions' import { ExtractInputSpecType } from './input/builder/inputSpec' -export type RunActionInput = - | Input - | ((prev?: { spec: IST.InputSpec; value: Input | null }) => Input) +/** Receives the action's form as `getInput` opened it and returns the input to submit. */ +export type RunActionInput = (form: { + spec: IST.InputSpec + value: T.DeepPartial | null +}) => Input +/** + * Runs an action of this service, or of another one whose `access` admits it. + * An action with input opens its form first, so the input is checked against + * the form it answers. + */ export const runAction = async < Input extends Record, >(options: { effects: T.Effects - // packageId?: T.PackageId + packageId?: T.PackageId actionId: T.ActionId + /** Seeds the form, including the values its dynamic fields are computed from. */ + prefill?: T.DeepPartial | null input?: RunActionInput }) => { - if (options.input) { - if (options.input instanceof Function) { - const prev = await options.effects.action.getInput({ - // packageId: options.packageId, - actionId: options.actionId, - }) - const input = options.input( - prev - ? { spec: prev.spec as IST.InputSpec, value: prev.value as Input } - : undefined, - ) - return options.effects.action.run({ - // packageId: options.packageId, - actionId: options.actionId, - input, - }) - } else { - return options.effects.action.run({ - // packageId: options.packageId, - actionId: options.actionId, - input: options.input, - }) - } - } else { - return options.effects.action.run({ - // packageId: options.packageId, - actionId: options.actionId, - }) + const { effects, packageId, actionId } = options + if (!options.input) return effects.action.run({ packageId, actionId }) + const form = await effects.action.getInput({ + packageId, + actionId, + prefill: (options.prefill ?? null) as Record | null, + }) + if (!form) { + throw new Error( + `Action ${actionId} of ${packageId ?? 'this service'} has no input form`, + ) } + return effects.action.run({ + packageId, + actionId, + eventId: form.eventId, + input: options.input({ + spec: form.spec as IST.InputSpec, + value: form.value as T.DeepPartial | null, + }), + }) } type GetActionInputType> = A extends ActionInfo ? I : never diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/EffectsRunActionParams.ts b/shared-libs/ts-modules/start-core/lib/osBindings/EffectsRunActionParams.ts index eb93655ad4..e8d31966e9 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/EffectsRunActionParams.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/EffectsRunActionParams.ts @@ -1,9 +1,14 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ActionId } from './ActionId' +import type { Guid } from './Guid' import type { PackageId } from './PackageId' export type EffectsRunActionParams = { packageId?: PackageId + /** + * The `eventId` from the `get-input` whose form this input answers. + */ + eventId?: Guid actionId: ActionId input: any } diff --git a/shared-libs/ts-modules/start-core/lib/test/runAction.test.ts b/shared-libs/ts-modules/start-core/lib/test/runAction.test.ts new file mode 100644 index 0000000000..1d23245c62 --- /dev/null +++ b/shared-libs/ts-modules/start-core/lib/test/runAction.test.ts @@ -0,0 +1,126 @@ +import { runAction } from '../actions' +import { Action } from '../actions/setupActions' +import { InputSpec } from '../actions/input/builder/inputSpec' +import { Value } from '../actions/input/builder/value' +import { Effects } from '../Effects' + +const metadata = { + name: 'Attach', + description: '', + warning: null, + allowedStatuses: 'any' as const, + group: null, + visibility: 'hidden' as const, + access: 'public' as const, +} + +/** Routes the effects to one action the way StartOS does: a fresh event id per call unless one is named. */ +function hostedBy(action: ReturnType, caller: string) { + let next = 0 + const effectsFor = (eventId: string) => ({ eventId }) as unknown as Effects + return { + eventId: 'caller-event', + action: { + getInput: jest.fn(async ({ prefill }: { prefill?: unknown }) => + action.getInput({ + effects: effectsFor(`event-${next++}`), + prefill: (prefill ?? null) as any, + caller, + }), + ), + run: jest.fn( + async ({ eventId, input }: { eventId?: string; input?: any }) => + action.run({ + effects: effectsFor(eventId ?? `event-${next++}`), + input, + caller, + }), + ), + }, + } as unknown as Effects +} + +function attachAction(ran: jest.Mock) { + return Action.withInput( + 'attach', + metadata, + async ({ prefill }) => + InputSpec.of({ + hostId: Value.hidden(), + address: Value.select({ + name: 'Address', + default: 'new', + values: { new: 'New', [`${(prefill as any)?.hostId}-0`]: 'Existing' }, + }), + }), + async () => null, + async ({ input, caller }) => { + ran(input, caller) + return null + }, + ) +} + +describe('runAction', () => { + test('answers the form it opened, under that form’s event id', async () => { + const ran = jest.fn() + const effects = hostedBy(attachAction(ran), 'bitcoind') + + await runAction({ + effects, + packageId: 'tor', + actionId: 'attach', + prefill: { hostId: 'peer' }, + input: ({ spec, value }) => { + expect(Object.keys((spec.address as any).values)).toEqual([ + 'new', + 'peer-0', + ]) + expect(value).toBeNull() + return { hostId: 'peer', address: 'peer-0' } + }, + }) + + expect(effects.action.getInput).toHaveBeenCalledWith({ + packageId: 'tor', + actionId: 'attach', + prefill: { hostId: 'peer' }, + }) + expect(effects.action.run).toHaveBeenCalledWith( + expect.objectContaining({ + packageId: 'tor', + actionId: 'attach', + eventId: 'event-0', + }), + ) + expect(ran).toHaveBeenCalledWith( + { hostId: 'peer', address: 'peer-0' }, + 'bitcoind', + ) + }) + + test('an input run under an event id no form was opened for is refused', async () => { + const ran = jest.fn() + const effects = hostedBy(attachAction(ran), 'bitcoind') + + await expect( + effects.action.run({ + actionId: 'attach', + input: { hostId: 'peer', address: 'new' }, + }), + ).rejects.toThrow('getActionInput has not been called') + expect(ran).not.toHaveBeenCalled() + }) + + test('an action with no input runs without opening a form', async () => { + const run = jest.fn(async () => null) + const effects = { + action: { getInput: jest.fn(), run }, + } as unknown as Effects + + await runAction({ effects, packageId: 'tor', actionId: 'reset' }) + + expect(effects.action.getInput).not.toHaveBeenCalled() + expect(run).toHaveBeenCalledWith({ packageId: 'tor', actionId: 'reset' }) + }) +})