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 e5b2e74e96..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 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 c05c66bd08..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, @@ -54,10 +55,14 @@ 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 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' }) + }) +})