Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion projects/start-os/man/start-container-action-run.1
Original file line number Diff line number Diff line change
Expand Up @@ -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<PACKAGE_ID>\fR
Package identifier
.TP
\fB\-\-event\-id\fR \fI<EVENT_ID>\fR
Unique event identifier
.TP
\fB\-\-format\fR

.TP
Expand Down
8 changes: 8 additions & 0 deletions projects/start-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion projects/start-sdk/docs/src/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`:
Expand Down
6 changes: 6 additions & 0 deletions shared-libs/crates/start-core/src/service/effects/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ pub struct RunActionParams {
#[ts(optional)]
#[arg(short, long, help = "help.arg.package-id")]
package_id: Option<PackageId>,
/// The `eventId` from the `get-input` whose form this input answers.
#[ts(optional)]
#[arg(long, help = "help.arg.event-id")]
event_id: Option<Guid>,
#[arg(help = "help.arg.action-id")]
action_id: ActionId,
#[ts(type = "any")]
Expand All @@ -184,11 +188,13 @@ async fn run_action(
RunActionParams {
procedure_id,
package_id,
event_id,
action_id,
input,
}: RunActionParams,
) -> Result<Option<ActionResult>, 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);
Expand Down
5 changes: 5 additions & 0 deletions shared-libs/ts-modules/start-core/lib/Effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
Manifest,
HostnameInfo,
Progress,
Guid,
} from './osBindings'
import {
PackageId,
Expand Down Expand Up @@ -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<string, unknown> | null
}): Promise<ActionInput | null>
run<Input extends Record<string, unknown>>(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<ActionResult | null>
createTask(options: CreateTaskParams): Promise<null>
Expand Down
65 changes: 33 additions & 32 deletions shared-libs/ts-modules/start-core/lib/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> =
| 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<Input> = (form: {
spec: IST.InputSpec
value: T.DeepPartial<Input> | 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<string, unknown>,
>(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<Input> | null
input?: RunActionInput<Input>
}) => {
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<string, unknown> | 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<Input> | null,
}),
})
}
type GetActionInputType<A extends ActionInfo<T.ActionId, any>> =
A extends ActionInfo<T.ActionId, infer I> ? I : never
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
126 changes: 126 additions & 0 deletions shared-libs/ts-modules/start-core/lib/test/runAction.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof attachAction>, 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<string>(),
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' })
})
})
Loading