From 72be028689f569ec49905c053f33524e956110e0 Mon Sep 17 00:00:00 2001 From: Rami Date: Mon, 26 Oct 2026 19:12:15 +0000 Subject: [PATCH 1/2] feat(api): a record names who did it, and a connection says whom it acts as A caller has a name beside its id: a signed-in person's as the name claim of their token gives it, which a token must now hold, a key's id, the local developer's account on the computer, os.userInfo().username, or local where the computer names none, and for a playbook acting as itself the playbook's id. Every field that says who did something names the caller by id and name: a playbook's and a definition's created_by, a connection's added_by and connected_by, a run's started_by, an answer's answered_by, and a prediction version's trained_by, approved_by and refused_by. The fact that makes each record carries the caller in its data; the context's by stays the id. The cancel of a run keeps its by, an approval of a connection compares the adder by id, and a run a workflow's call starts names the caller who started it. The instructions of an MCP connection open with whom it acts as, by name and id, and in which org: a person or the local developer as Name (id), a key as the key and its id. The rest is worded tighter, every instruction kept, so the whole text stays within 2,000 characters for a person named in 20 characters with a user id of 32 and an org id of 30, which startup checks. The OpenAPI document, the READMEs and the reference pages describe each field as the caller who did it, by id and name. Co-Authored-By: Claude Fable 5.1 --- .../src/testing/computation-runs.ts | 2 +- capabilities/coordination/README.md | 2 +- .../src/calls/function-calls.test.ts | 8 +- .../src/calls/waiting-calls.test.ts | 8 +- .../src/capability/started-run.test.ts | 8 +- .../src/runs/run-attributes.test.ts | 1 + .../coordination/src/testing/workflows.ts | 1 + capabilities/interaction/README.md | 2 +- .../src/capability/request-cancels.test.ts | 2 +- .../src/replies/reply-races.test.ts | 4 +- .../src/requests/answer-interaction.test.ts | 6 +- .../src/requests/answer-interaction.ts | 6 +- .../src/requests/open-requests.test.ts | 6 +- .../src/run/interaction-run.test.ts | 2 +- .../src/schedule/request-endings.ts | 2 +- .../interaction/src/testing/call-runs.ts | 2 +- .../src/testing/interaction-harness.ts | 8 +- capabilities/prediction/README.md | 4 +- .../prediction/measure/order-history.ts | 5 +- .../src/build-testing/playbook-calls.ts | 8 +- .../src/build-work/build-lifecycle.test.ts | 4 +- .../src/dataset-testing/projected-datasets.ts | 7 +- .../prediction/src/facts/model-facts.ts | 8 +- .../src/facts/prediction-facts.test.ts | 4 +- .../src/models/activate-version.test.ts | 1 + .../src/models/model-decider.test.ts | 2 +- .../prediction/src/scoring/version-card.ts | 4 +- .../prediction/src/shadow/approval.test.ts | 7 +- .../prediction/src/shadow/model-lifecycle.ts | 12 +- .../src/testing/cancels-in-a-call.ts | 8 +- .../prediction/src/testing/prediction-runs.ts | 2 +- .../prediction/src/testing/upload-calls.ts | 8 +- .../reasoning/src/catalog/list-models.test.ts | 16 +- .../reasoning/src/testing/reasoning-runs.ts | 2 +- .../recall/src/testing/recall-runs.ts | 2 +- docs/assets/openapi.json | 591 ++++++++++++++++-- docs/engineering/reference/mcp.md | 2 +- .../engineering/reference/reasoning-format.md | 2 +- docs/guides/triggers.md | 6 +- docs/reference/authentication.md | 31 +- docs/reference/http.md | 26 +- docs/reference/interaction-format.md | 2 +- docs/reference/mcp.md | 5 +- docs/reference/workflow-format.md | 4 +- packages/api/README.md | 6 +- packages/api/src/bounds/served-bounds.ts | 9 +- .../api/src/hand-off/caller-hand-off.test.ts | 8 +- packages/api/src/index.ts | 2 + packages/api/src/mcp/instructions.test.ts | 93 +-- packages/api/src/mcp/instructions.ts | 54 +- packages/api/src/mcp/mcp-clients.test.ts | 2 +- packages/api/src/mcp/mcp-connection.ts | 27 +- packages/api/src/mcp/mcp-own-org.test.ts | 6 +- packages/api/src/mcp/mcp-server-factory.ts | 10 +- packages/api/src/mcp/mcp-sign-in.test.ts | 8 +- .../src/mcp/tool-calling-instructions.test.ts | 44 +- packages/api/src/testing/api-calls.ts | 4 +- .../api/src/testing/served-instructions.ts | 14 + packages/api/src/testing/signed-in.ts | 1 + packages/api/src/tools/linked-answers.test.ts | 2 +- packages/connections/README.md | 6 +- .../src/operations/add-connection.test.ts | 2 +- .../src/operations/add-connection.ts | 4 +- .../src/operations/registry-access.ts | 14 +- .../src/page/consent-finish.test.ts | 1 + .../connections/src/page/consent-finish.ts | 13 +- .../src/page/give-credential.test.ts | 23 +- .../connections/src/page/give-credential.ts | 3 +- .../src/page/link-operations.test.ts | 13 +- .../connections/src/page/link-operations.ts | 18 +- .../connections/src/policy/who-adds.test.ts | 11 +- .../src/registry/connection-commands.ts | 12 +- .../src/registry/connection-events.ts | 4 +- .../connections/src/registry/connection.ts | 7 +- .../src/registry/consent-decisions.test.ts | 13 +- .../src/registry/registry-decider.test.ts | 9 +- .../src/registry/registry-decisions.ts | 22 +- .../src/registry/registry-lookup.ts | 2 +- .../connections/src/registry/registry.test.ts | 17 +- packages/connections/src/registry/registry.ts | 6 +- packages/connections/src/testing/additions.ts | 4 + packages/connections/src/testing/harness.ts | 21 +- packages/definitions/README.md | 6 +- packages/definitions/measure/measure-loads.ts | 8 +- .../analytics/get-playbook-analytics.test.ts | 7 +- .../src/attempts/recorded-attempt.test.ts | 5 +- .../src/cancellation/cancel-run.test.ts | 2 +- .../src/capability/capability.test.ts | 2 +- .../src/events/playbook-facts.test.ts | 17 +- .../src/events/run-start-facts.test.ts | 5 +- .../src/journal-testing/run-links.ts | 1 + .../src/journal/run-journal.test.ts | 3 +- .../operations/claims-of-another-type.test.ts | 3 +- .../src/operations/create-definition.test.ts | 28 +- .../src/operations/create-definition.ts | 9 +- .../src/operations/deferred-run.test.ts | 2 +- .../src/operations/get-definition.test.ts | 2 +- .../src/operations/isolation.test.ts | 4 +- .../src/operations/list-definitions.test.ts | 2 +- .../src/operations/retire-definition.test.ts | 2 +- .../src/operations/run-definition.test.ts | 19 +- .../src/operations/run-failures.test.ts | 2 +- .../src/operations/run-requests.ts | 20 +- .../src/operations/start-version.test.ts | 7 +- .../src/operations/update-definition.test.ts | 2 +- .../plain-language/definition-words.test.ts | 2 +- .../src/plain-language/reading-words.test.ts | 2 +- .../src/plain-language/run-words.test.ts | 2 +- .../presenting/definition-presenter.test.ts | 5 +- .../src/presenting/presenter-catalog.test.ts | 7 +- .../src/presenting/run-presenter.test.ts | 31 +- .../src/presenting/run-presenter.ts | 3 +- .../presenting/run-starts-presenter.test.ts | 11 +- .../definitions/src/reading/list-runs.test.ts | 12 +- .../definitions/src/reading/paging.test.ts | 3 +- .../src/reading/run-status.test.ts | 5 +- .../src/registry/definition-changes.test.ts | 9 +- .../src/registry/definition-commands.ts | 3 + .../src/registry/definition-events.ts | 8 +- .../src/registry/definition-registry.ts | 5 +- .../src/registry/definition-versions.test.ts | 6 +- .../src/registry/definition.test.ts | 2 +- .../definitions/src/registry/definition.ts | 3 +- .../src/registry/definitions-decider.test.ts | 16 +- .../src/registry/registry-decisions.ts | 6 +- .../src/run-work/answer-command.test.ts | 21 +- .../src/run-work/work-command.test.ts | 5 +- .../definitions/src/run-work/work-recorder.ts | 26 +- .../src/runs/caller-cancels.test.ts | 15 +- .../src/runs/cancelled-calls.test.ts | 5 +- .../definitions/src/runs/run-calls.test.ts | 5 +- packages/definitions/src/runs/run-commands.ts | 5 +- .../definitions/src/runs/run-decider.test.ts | 19 +- .../definitions/src/runs/run-decisions.ts | 3 +- .../definitions/src/runs/run-deferral.test.ts | 23 +- .../definitions/src/runs/run-depth.test.ts | 13 +- .../definitions/src/runs/run-endings.test.ts | 3 +- packages/definitions/src/runs/run-events.ts | 10 +- .../definitions/src/runs/run-resumes.test.ts | 5 +- .../definitions/src/runs/run-settler.test.ts | 2 +- .../definitions/src/runs/run-starts.test.ts | 15 +- packages/definitions/src/runs/run-state.ts | 16 +- .../src/runs/run-tool-starts.test.ts | 18 +- packages/definitions/src/runs/run.test.ts | 2 +- packages/definitions/src/runs/run.ts | 3 +- packages/definitions/src/testing/callers.ts | 18 +- .../definitions/src/testing/resumed-runs.ts | 1 + .../src/used-by/definition-users.test.ts | 2 +- packages/identity/README.md | 8 +- packages/identity/measure.ts | 10 +- .../identity/src/access/authenticator.test.ts | 34 +- packages/identity/src/access/authenticator.ts | 19 +- packages/identity/src/access/principal.ts | 33 +- .../src/access/signed-in-person.test.ts | 11 +- .../identity-service/identity-service.test.ts | 26 +- .../issuer-key-sources.test.ts | 9 +- .../src/identity-service/key-choice.test.ts | 9 +- .../src/identity-service/person-names.test.ts | 53 ++ .../src/identity-service/sessions.test.ts | 22 +- packages/identity/src/index.ts | 9 +- packages/identity/src/keys/api-key.ts | 3 +- .../identity/src/testing/fake-issuer.test.ts | 6 +- packages/identity/src/testing/fake-issuer.ts | 2 + packages/identity/src/testing/index.ts | 2 +- packages/identity/src/testing/principals.ts | 6 +- .../identity/src/testing/signed-tokens.ts | 1 + .../identity/src/tokens/token-verification.ts | 28 +- packages/operations/README.md | 4 +- packages/operations/src/caller/caller.test.ts | 15 +- packages/operations/src/caller/caller.ts | 10 +- packages/operations/src/index.ts | 3 + .../operations/src/ledger/context.test.ts | 17 + packages/operations/src/ledger/context.ts | 16 + packages/operations/src/testing/callers.ts | 11 +- packages/playbooks/README.md | 2 +- .../src/operations/create-playbook.test.ts | 12 +- .../src/operations/create-playbook.ts | 6 +- .../src/operations/get-playbook.test.ts | 2 +- .../src/operations/isolation.test.ts | 8 +- .../src/operations/list-playbooks.test.ts | 2 +- .../src/operations/retire-playbook.test.ts | 2 +- .../src/operations/update-playbook.test.ts | 2 +- .../src/plain-language/playbook-words.test.ts | 2 +- .../src/registry/created-playbooks.test.ts | 15 +- .../src/registry/playbook-commands.ts | 3 + .../playbooks/src/registry/playbook-events.ts | 9 +- packages/playbooks/src/registry/playbook.ts | 4 +- .../src/registry/registry-decider.test.ts | 18 +- .../src/registry/registry-decisions.ts | 4 +- packages/playbooks/src/registry/registry.ts | 7 +- packages/playbooks/src/testing/callers.ts | 22 +- .../src/composition/composition-root.test.ts | 8 +- .../connections/connections-over-http.test.ts | 6 +- .../connections/connections-over-mcp.test.ts | 7 +- .../connections/connections-signed-in.test.ts | 3 +- .../interaction/interaction-over-http.test.ts | 5 +- packages/server/src/main.test.ts | 5 +- .../mcp/answering-what-runs-wait-on.test.ts | 2 +- .../server/src/mcp/mcp-endpoint-keys.test.ts | 2 +- packages/server/src/mcp/mcp-endpoint.test.ts | 23 +- .../server/src/mcp/speaking-to-agents.test.ts | 6 +- .../src/openapi/served-document.test.ts | 50 ++ .../src/playbooks/playbooks-over-http.test.ts | 9 +- .../recall/what-the-person-wants-kept.test.ts | 2 +- .../server/src/sign-in/issuer-over-https.ts | 1 + .../src/sign-in/signed-in-persons.test.ts | 16 +- .../workflow-runs/reactions-over-http.test.ts | 4 +- .../workflow-runs/reactions-over-mcp.test.ts | 6 +- .../workflow-runs/several-triggers.test.ts | 16 +- .../workflow-access-over-http.test.ts | 13 +- .../workflow-runs/workflows-over-http.test.ts | 9 +- .../workflows/reaction-dependencies.test.ts | 1 + .../src/search/search-in-org.test.ts | 9 +- .../src/search/search-providers.test.ts | 8 +- .../src/testing/playbook-tool-calls.ts | 1 + packages/workflow-host/host-process.ts | 2 +- packages/workflow-host/measure/idle-views.ts | 7 +- .../workflow-host/measure/measured-host.ts | 2 +- packages/workflow-host/measure/rebuild.ts | 2 +- .../workflow-host/measure/waiting-host.ts | 2 +- .../folder-testing/projected-folders.test.ts | 21 +- .../src/folder-testing/tally-harness.ts | 7 +- .../src/follower/followed-events.test.ts | 12 +- .../src/follower/follower-records.test.ts | 10 +- .../src/follower/pass-types.test.ts | 5 +- .../src/follower/playbook-discovery.test.ts | 2 +- .../projector/playbook-definitions.test.ts | 2 +- .../src/projector/projector-wake-up.test.ts | 12 +- .../src/reaction-testing/playbook-writes.ts | 13 +- .../src/reactions/run-workflows.test.ts | 2 +- .../src/settlement/settle-back-off.test.ts | 2 +- .../workflow-host/src/testing/known-runs.ts | 2 +- .../src/triggers/definition-records.test.ts | 19 +- .../src/triggers/trigger-rebuild.test.ts | 10 +- .../src/views-testing/playbook-appends.ts | 9 +- .../src/waiting-testing/lost-run.ts | 5 +- .../src/waiting/cancel-requests.test.ts | 2 +- .../src/waiting/child-endings.test.ts | 7 +- 238 files changed, 2096 insertions(+), 652 deletions(-) create mode 100644 packages/identity/src/identity-service/person-names.test.ts diff --git a/capabilities/computation/src/testing/computation-runs.ts b/capabilities/computation/src/testing/computation-runs.ts index 44bcf6460..a153bbe62 100644 --- a/capabilities/computation/src/testing/computation-runs.ts +++ b/capabilities/computation/src/testing/computation-runs.ts @@ -28,7 +28,7 @@ const run: RunContext = { id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', org: 'acme', playbook: 'alpha', - caller: { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, + caller: { id: 'acme-admin', name: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, definition: { name: 'pace', version: 1 }, journal: recordingJournal(), lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, diff --git a/capabilities/coordination/README.md b/capabilities/coordination/README.md index b1696e3cb..53840400b 100644 --- a/capabilities/coordination/README.md +++ b/capabilities/coordination/README.md @@ -47,7 +47,7 @@ Parsing keeps the document with the locator of its YAML and its compiled input s ## A run of a workflow -`run_definition` of a workflow, once [its input schema](#the-input-of-a-workflow) takes the input of a caller, starts its run with the `started` input: the document, each expression in it the JavaScript its save's check stripped of its types where stripping changed it, at its place (`runnableDocument`, `src/document/workflow-document.ts`), so the engine evaluates what the check passed and strips nothing, the input, the run's limits, a random seed for the draws of the run, and as attributes the org, the playbook, the run id, the definition's name and version, the caller who started it, its reaction depth, `depth`, its call depth, `call_depth`, the call it answers, `called_by`, the trigger that started it, `trigger`, the interval after which the run of each step that calls a definition whose runs resume may be resumed, by the step's reference, `resumes_after_ms_by_task`, through the run context's `resumesAfterOf`, and `lineage`: the id of the `run_started` that began the run and the run at the top of its tree, which the workflow host writes with each record of the run (`@on.auto/workflow-host`). The host builds each record's context from these attributes (`runLogContextOf`), so every record of the run log carries the run's definition, its caller as `by`, its chain and the time of its input, and the engine's state format governs its data alone. The run log is `run-logs/` under the playbook, beside the run's own stream, `runs/`, which records that the run finishes later, with an empty record, so the run stays `started` until its workflow settles it; a workflow that ends in its first input settles the run before the deferral, and `run_definition` answers its ending. The limits name, for each task that calls `run_definition` with a `type` and a `name` written out, the longest a run of that definition may take, for the task's `input` when it is written out with no expression in it, `{}` when it is left out, through the run context's `longestRunFor`, and for any input otherwise, through its `longestRunOf`, plus `callMarginMs` (`src/runs/call-limits.ts`), read when the run starts, so a call of a prediction function waits 10 s and a minute for a question written out and its build's longest run and a minute for a build or an input computed at run time, which `src/capability/prediction-calls.test.ts` runs against the prediction capability itself; whether the input holds an expression is the engine's own walk of a template, `holdsAnExpression`; every other call has `longestCallMs`. A retry with the run id of a run that is going answers the run as it stands. A run never starts twice in one log, so a retry with the run id of a run that ended without a final result, `unavailable` or `failed`, is rejected with `conflict`: run the workflow again under a new run id. While the server is stopping, `run_definition` of a workflow is rejected with `unavailable`. +`run_definition` of a workflow, once [its input schema](#the-input-of-a-workflow) takes the input of a caller, starts its run with the `started` input: the document, each expression in it the JavaScript its save's check stripped of its types where stripping changed it, at its place (`runnableDocument`, `src/document/workflow-document.ts`), so the engine evaluates what the check passed and strips nothing, the input, the run's limits, a random seed for the draws of the run, and as attributes the org, the playbook, the run id, the definition's name and version, the caller who started it, with its name, which every run a call of the workflow starts records as its `started_by`, its reaction depth, `depth`, its call depth, `call_depth`, the call it answers, `called_by`, the trigger that started it, `trigger`, the interval after which the run of each step that calls a definition whose runs resume may be resumed, by the step's reference, `resumes_after_ms_by_task`, through the run context's `resumesAfterOf`, and `lineage`: the id of the `run_started` that began the run and the run at the top of its tree, which the workflow host writes with each record of the run (`@on.auto/workflow-host`). The host builds each record's context from these attributes (`runLogContextOf`), so every record of the run log carries the run's definition, its caller as `by`, its chain and the time of its input, and the engine's state format governs its data alone. The run log is `run-logs/` under the playbook, beside the run's own stream, `runs/`, which records that the run finishes later, with an empty record, so the run stays `started` until its workflow settles it; a workflow that ends in its first input settles the run before the deferral, and `run_definition` answers its ending. The limits name, for each task that calls `run_definition` with a `type` and a `name` written out, the longest a run of that definition may take, for the task's `input` when it is written out with no expression in it, `{}` when it is left out, through the run context's `longestRunFor`, and for any input otherwise, through its `longestRunOf`, plus `callMarginMs` (`src/runs/call-limits.ts`), read when the run starts, so a call of a prediction function waits 10 s and a minute for a question written out and its build's longest run and a minute for a build or an input computed at run time, which `src/capability/prediction-calls.test.ts` runs against the prediction capability itself; whether the input holds an expression is the engine's own walk of a template, `holdsAnExpression`; every other call has `longestCallMs`. A retry with the run id of a run that is going answers the run as it stands. A run never starts twice in one log, so a retry with the run id of a run that ended without a final result, `unavailable` or `failed`, is rejected with `conflict`: run the workflow again under a new run id. While the server is stopping, `run_definition` of a workflow is rejected with `unavailable`. `send_run_event` gives the run an `event_received` input, with an id made when the event has none, the time it was sent, and, when it has no source, `/callers/` of the caller who sent it, a source under a prefix the playbook keeps for itself, so that a sent event always says where it came from and no event from outside poses as one. An event the run took before is answered as delivered, since an event is taken once by its id; a run that has not started yet, or has ended, is `not_found`; and a run that cannot take the event at that moment, because its log kept changing or the server is stopping, is `unavailable`. Since a `listen` filter matches on the event's attributes, an event may not take a type or a source of what the playbook records itself: `refusingThePlaybooksOwnAttributes` of `@on.auto/definitions` refuses them with `invalid_input` at `/event/type` and `/event/source`, as `publish_event` does. Its `source` is a URI reference that is not empty, `EventSourceSchema`, and its text follows the rules of a published event too, through `refusingForbiddenCharacters` and `refusingBlankText` of `@on.auto/definitions`: no control character, lone surrogate or noncharacter, and a type, id and subject that hold a character that is not a space. diff --git a/capabilities/coordination/src/calls/function-calls.test.ts b/capabilities/coordination/src/calls/function-calls.test.ts index f517d1ebd..c990f5d87 100644 --- a/capabilities/coordination/src/calls/function-calls.test.ts +++ b/capabilities/coordination/src/calls/function-calls.test.ts @@ -17,7 +17,13 @@ const run = { playbook: 'alpha', run_id: workflowRun, definition: { name: 'triage', version: 2 }, - caller: { id: 'acme-admin', org: 'acme', permissions: ['playbook:read', 'playbook:write'], playbooks: '*' }, + caller: { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: ['playbook:read', 'playbook:write'], + playbooks: '*', + }, }, }; diff --git a/capabilities/coordination/src/calls/waiting-calls.test.ts b/capabilities/coordination/src/calls/waiting-calls.test.ts index 7f6a310e9..b1a6f9162 100644 --- a/capabilities/coordination/src/calls/waiting-calls.test.ts +++ b/capabilities/coordination/src/calls/waiting-calls.test.ts @@ -17,7 +17,13 @@ const run = { playbook: 'alpha', run_id: workflowRun, definition: { name: 'triage', version: 2 }, - caller: { id: 'acme-admin', org: 'acme', permissions: ['playbook:read', 'playbook:write'], playbooks: '*' }, + caller: { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: ['playbook:read', 'playbook:write'], + playbooks: '*', + }, call_depth: 3, }, }; diff --git a/capabilities/coordination/src/capability/started-run.test.ts b/capabilities/coordination/src/capability/started-run.test.ts index 8f475d96d..3133164c1 100644 --- a/capabilities/coordination/src/capability/started-run.test.ts +++ b/capabilities/coordination/src/capability/started-run.test.ts @@ -40,7 +40,13 @@ describe('the run a run of a workflow starts', () => { playbook: 'alpha', run_id: runId, definition: { name: 'flow', version: 1 }, - caller: { id: 'acme-admin', org: 'acme', playbooks: '*', permissions: ['playbook:read', 'playbook:write'] }, + caller: { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + playbooks: '*', + permissions: ['playbook:read', 'playbook:write'], + }, depth: 0, call_depth: 0, lineage: { correlation: runId }, diff --git a/capabilities/coordination/src/runs/run-attributes.test.ts b/capabilities/coordination/src/runs/run-attributes.test.ts index 167883ed6..77795a7a8 100644 --- a/capabilities/coordination/src/runs/run-attributes.test.ts +++ b/capabilities/coordination/src/runs/run-attributes.test.ts @@ -10,6 +10,7 @@ const attributes: RunAttributes = { definition: { name: 'payment', version: 2 }, caller: { id: 'operator', + name: 'operator', org: 'acme', permissions: ['org:read', 'org:write', 'playbook:read', 'playbook:write'], playbooks: ['accounts'], diff --git a/capabilities/coordination/src/testing/workflows.ts b/capabilities/coordination/src/testing/workflows.ts index 8fa0304d8..cc26dc5d8 100644 --- a/capabilities/coordination/src/testing/workflows.ts +++ b/capabilities/coordination/src/testing/workflows.ts @@ -24,6 +24,7 @@ export interface InterpretOptions { export const acmeCaller: CallerIdentity = { id: 'acme-admin', + name: 'acme-admin', org: 'acme', permissions: ['playbook:read', 'playbook:write'], playbooks: '*', diff --git a/capabilities/interaction/README.md b/capabilities/interaction/README.md index b4d7ff76d..f8a9e592e 100644 --- a/capabilities/interaction/README.md +++ b/capabilities/interaction/README.md @@ -110,7 +110,7 @@ Attempts follow `attemptSchedule` (`src/schedule/attempt-schedule.ts`): five, th ## Answering and listing -`defineAnswerInteraction(capabilities)` is `answer_interaction`, `POST /runs/{run_id}/answer` under `playbook:write`, for a request a function's run waits on, made over the capabilities the server composes. The answer is checked against the answer schema the request recorded, 64 KiB as JSON and 512 levels at most, its issues in the words of `wordedIssuesOf` of `@on.auto/definitions` with pointers under `/answer` (`src/requests/answer-check.ts`), then against the `answerIssues` of the capability of the run that asked, given the run's work, so a prediction build refuses a candidate it was never sent, each issue `invalid_input` under `/answer`, and recorded on the run as one fact, `request_answered { answer, claimed_for? }`, its actor the caller and its time its own, through `playbookBoundAnswerer` of `@on.auto/definitions`; `claimed_for` is what the caller says it answers for, at most 256 bytes and never checked. The operation answers the recorded fact, `{ run_id, answer_id, answer, claimed_for?, reply?, answered_by, answered_at }`. The run decider takes one answer: the same answer again records nothing and answers the fact the run took, and another one is `conflict`, as is an answer to a request that has ended or whose run was asked to cancel. The run is settled from the fact by the open requests' due work within the host's sweep, a second by default, `succeeded` with the answer as its output and an empty record, as the caller that answered; a settlement that says nobody answered is refused once the run took an answer, and a cancel settles with it. `listInteractions` is `list_interactions`, `GET /interactions` under `playbook:read`: the open requests of the playbook, newest first, filtered by `to`, `type` and `function`, the last naming one function with `type`, since a name is unique within its type, in pages, each with the `type` of the function that asked, its `delivery`, its `answer_schema`, and for a request whose function reads replies its `conversation`, `answerer` and `reply_refusals`. +`defineAnswerInteraction(capabilities)` is `answer_interaction`, `POST /runs/{run_id}/answer` under `playbook:write`, for a request a function's run waits on, made over the capabilities the server composes. The answer is checked against the answer schema the request recorded, 64 KiB as JSON and 512 levels at most, its issues in the words of `wordedIssuesOf` of `@on.auto/definitions` with pointers under `/answer` (`src/requests/answer-check.ts`), then against the `answerIssues` of the capability of the run that asked, given the run's work, so a prediction build refuses a candidate it was never sent, each issue `invalid_input` under `/answer`, and recorded on the run as one fact, `request_answered { answer, claimed_for? }`, its actor the caller and its time its own, through `playbookBoundAnswerer` of `@on.auto/definitions`; `claimed_for` is what the caller says it answers for, at most 256 bytes and never checked. The operation answers the recorded fact, `{ run_id, answer_id, answer, claimed_for?, reply?, answered_by, answered_at }`, `answered_by` the caller who answered, by `id` and `name`, which `request_answered` holds beside the context's `by`, the id alone, and for a reply the playbook acting as itself. The run decider takes one answer: the same answer again records nothing and answers the fact the run took, and another one is `conflict`, as is an answer to a request that has ended or whose run was asked to cancel. The run is settled from the fact by the open requests' due work within the host's sweep, a second by default, `succeeded` with the answer as its output and an empty record, as the caller that answered; a settlement that says nobody answered is refused once the run took an answer, and a cancel settles with it. `listInteractions` is `list_interactions`, `GET /interactions` under `playbook:read`: the open requests of the playbook, newest first, filtered by `to`, `type` and `function`, the last naming one function with `type`, since a name is unique within its type, in pages, each with the `type` of the function that asked, its `delivery`, its `answer_schema`, and for a request whose function reads replies its `conversation`, `answerer` and `reply_refusals`. ## Words diff --git a/capabilities/interaction/src/capability/request-cancels.test.ts b/capabilities/interaction/src/capability/request-cancels.test.ts index 00a116cec..2eba39eb8 100644 --- a/capabilities/interaction/src/capability/request-cancels.test.ts +++ b/capabilities/interaction/src/capability/request-cancels.test.ts @@ -35,7 +35,7 @@ const asked = { run: address, kind: 'requested', reason: 'Not needed any more' } const cancelledAsAsked = { status: 'rejected', reason: 'cancelled', kind: 'requested', detail: asked.reason }; -const taken = { answer: 'yes', reply: takenReply, by: 'playbook:alpha', at }; +const taken = { answer: 'yes', reply: takenReply, by: { id: 'playbook:alpha', name: 'alpha' }, at }; const delivered = { type: 'delivery_succeeded', diff --git a/capabilities/interaction/src/replies/reply-races.test.ts b/capabilities/interaction/src/replies/reply-races.test.ts index ad509b8a6..39c575826 100644 --- a/capabilities/interaction/src/replies/reply-races.test.ts +++ b/capabilities/interaction/src/replies/reply-races.test.ts @@ -78,7 +78,9 @@ describe('a reply to a request answered while the conversation was read', () => expect( (await recordsOf(playbook.ledger, 'request_answered', 'reply_refused')).map(({ type, data }) => [type, data]), - ).toEqual([['request_answered', { answer: { choice: 'reject' } }]]); + ).toEqual([ + ['request_answered', { answer: { choice: 'reject' }, answered_by: { id: 'acme-admin', name: 'acme-admin' } }], + ]); expect(await playbook.runOf(runId)).toMatchObject({ output: { output: { choice: 'reject' } } }); }); }); diff --git a/capabilities/interaction/src/requests/answer-interaction.test.ts b/capabilities/interaction/src/requests/answer-interaction.test.ts index 2eefefdc4..8688f4432 100644 --- a/capabilities/interaction/src/requests/answer-interaction.test.ts +++ b/capabilities/interaction/src/requests/answer-interaction.test.ts @@ -67,6 +67,8 @@ async function askedApproval(document = approvalDocument()) { return { playbook, asked }; } +const thePlaybook = { id: 'playbook:alpha', name: 'alpha' }; + describe('a request asked through the inbox', () => { it('waits as a started run, listed among the open requests of the playbook', async () => { const { playbook, asked } = await askedApproval(); @@ -115,7 +117,7 @@ describe('an answer to a request', () => { answer_id: someText, answer: { choice: 'approve' }, claimed_for: 'ada', - answered_by: acmeAdmin.id, + answered_by: { id: acmeAdmin.id, name: acmeAdmin.name }, answered_at: someText, }, }); @@ -153,7 +155,7 @@ describe('an answer to a request a reply answered first', () => { expect(await playbook.call(answer, { run_id: askedRunId, answer: { choice: 'approve' } })).toMatchObject({ status: 'succeeded', - output: { answer: { choice: 'approve' }, reply: takenReply, answered_by: 'playbook:alpha' }, + output: { answer: { choice: 'approve' }, reply: takenReply, answered_by: thePlaybook }, }); }); }); diff --git a/capabilities/interaction/src/requests/answer-interaction.ts b/capabilities/interaction/src/requests/answer-interaction.ts index d7db9debb..ef9b92469 100644 --- a/capabilities/interaction/src/requests/answer-interaction.ts +++ b/capabilities/interaction/src/requests/answer-interaction.ts @@ -16,7 +16,9 @@ import { Conflict, InvalidInput, NotFound, + RecordedCallerSchema, defineCommand, + recordedCallerOf, } from '@on.auto/operations'; import { Effect, Schema } from 'effect'; @@ -54,7 +56,7 @@ const AnsweredRequestSchema = Schema.Struct({ description: 'The reply that brought the answer, when a reply did', }), ), - answered_by: Schema.String.annotate({ description: 'The id of the caller the answer came from' }), + answered_by: RecordedCallerSchema.annotate({ description: 'The caller who answered it, by id and name' }), answered_at: Schema.String.annotate({ description: 'When the answer was recorded, in ISO 8601 UTC' }), }).annotate({ identifier: 'AnsweredRequest', description: 'The answer a request took, as its run recorded it' }); @@ -115,7 +117,7 @@ function shownOf(id: string, { id: answerId, answer, claimedFor, reply, by, at } } const answered = Effect.fnUntraced(function* (checks: AnswerChecks, { id, answer, claimedFor }: Answering) { - const { id: answeredBy } = yield* Caller; + const answeredBy = recordedCallerOf(yield* Caller); const row = yield* openRequestRowIn(id); const run = yield* recordedRunInPlaybook(yield* PlaybookReader, id); if (run === undefined) { diff --git a/capabilities/interaction/src/requests/open-requests.test.ts b/capabilities/interaction/src/requests/open-requests.test.ts index b050c730f..1892e3ded 100644 --- a/capabilities/interaction/src/requests/open-requests.test.ts +++ b/capabilities/interaction/src/requests/open-requests.test.ts @@ -199,7 +199,11 @@ const delivered: Fact = { type: 'delivery_succeeded', data: { number: 1, ...answ const taken: Fact = { type: 'request_answered', - data: { answer: { choice: 'approve' }, reply: { id: '1699.2', sender: 'ada' } }, + data: { + answer: { choice: 'approve' }, + reply: { id: '1699.2', sender: 'ada' }, + answered_by: { id: 'playbook:alpha', name: 'alpha' }, + }, }; describe('the open request of a run asked to cancel', () => { diff --git a/capabilities/interaction/src/run/interaction-run.test.ts b/capabilities/interaction/src/run/interaction-run.test.ts index 6906d70c6..4ae1bc758 100644 --- a/capabilities/interaction/src/run/interaction-run.test.ts +++ b/capabilities/interaction/src/run/interaction-run.test.ts @@ -21,7 +21,7 @@ const aRun: RunContext = { id: runId, org: 'acme', playbook: 'alpha', - caller: { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, + caller: { id: 'acme-admin', name: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, definition: { name: 'approve-brief', version: 1 }, journal: recordingJournal(), lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: runId }, diff --git a/capabilities/interaction/src/schedule/request-endings.ts b/capabilities/interaction/src/schedule/request-endings.ts index 4c3c6d87a..a446731c6 100644 --- a/capabilities/interaction/src/schedule/request-endings.ts +++ b/capabilities/interaction/src/schedule/request-endings.ts @@ -13,7 +13,7 @@ export function expiredSettlement({ expires_at: expiresAt }: { readonly expires_ } export function answeredSettlement({ answer, by }: Pick): Settlement { - return { status: 'succeeded', output: answer, record: {}, by }; + return { status: 'succeeded', output: answer, record: {}, by: by.id }; } export function deliveredSettlement(at: string): Settlement { diff --git a/capabilities/interaction/src/testing/call-runs.ts b/capabilities/interaction/src/testing/call-runs.ts index 099ac78e2..bb3e9bb7e 100644 --- a/capabilities/interaction/src/testing/call-runs.ts +++ b/capabilities/interaction/src/testing/call-runs.ts @@ -38,7 +38,7 @@ function contextOf(journal: RecordingJournal): RunContext { id: callRunId, org: 'acme', playbook: 'alpha', - caller: { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, + caller: { id: 'acme-admin', name: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, definition: { name: 'thread-replies', version: 1 }, journal, lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: callRunId }, diff --git a/capabilities/interaction/src/testing/interaction-harness.ts b/capabilities/interaction/src/testing/interaction-harness.ts index 8260c48c8..fd0c8a6f7 100644 --- a/capabilities/interaction/src/testing/interaction-harness.ts +++ b/capabilities/interaction/src/testing/interaction-harness.ts @@ -30,7 +30,13 @@ import { requestsDue, type DueRequestItem, type RequestsDue } from '../schedule/ import { noTools, type ToolPorts } from './fake-tools.ts'; import { dueInBothLanes, firstOpenOf, performedAll, performedEach } from './harness-parts.ts'; -export const acmeAdmin: CallerIdentity = { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }; +export const acmeAdmin: CallerIdentity = { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: allPermissions, + playbooks: '*', +}; export const alpha = { org: 'acme', playbook: 'alpha' }; diff --git a/capabilities/prediction/README.md b/capabilities/prediction/README.md index bd9c976eb..78923faa8 100644 --- a/capabilities/prediction/README.md +++ b/capabilities/prediction/README.md @@ -62,13 +62,13 @@ With `accumulates`, each origin is the end of a period and the quantity the tota ## The ending of a build -Once every candidate is scored and every baseline recorded (`src/scoring/ending-steps.ts`), a winner is kept as the next version: `version_built` on `models/`, decided by `modelsDecider` (`src/models/model-decider.ts`) once a build, after `activation_refused { because: superseded }` for every earlier version still in its shadow or awaiting its approval, since the newest version is the one challenger, with the version's card (`src/scoring/version-card.ts`): the build, who trained it, the dataset's digests and figures, the file's digest, size and opset, the inputs, the features, the scores, the coverage, `p_better` against each baseline, the baselines' figures and the rule, at most 4 KiB as JSON, the features kept as content when the card would pass it, and the answer's `recipe` and `limitations` kept as content and named by their digests. The run then succeeds with `{ version, winner, candidates }`, each candidate with its verdict, as `playbook:`; `version` and `winner` are null when none won, and the build ends there. +Once every candidate is scored and every baseline recorded (`src/scoring/ending-steps.ts`), a winner is kept as the next version: `version_built` on `models/`, decided by `modelsDecider` (`src/models/model-decider.ts`) once a build, after `activation_refused { because: superseded }` for every earlier version still in its shadow or awaiting its approval, since the newest version is the one challenger, with the version's card (`src/scoring/version-card.ts`): the build, `trained_by`, the caller whose answer brought the candidates, by `id` and `name`, the dataset's digests and figures, the file's digest, size and opset, the inputs, the features, the scores, the coverage, `p_better` against each baseline, the baselines' figures and the rule, at most 4 KiB as JSON, the features kept as content when the card would pass it, and the answer's `recipe` and `limitations` kept as content and named by their digests. The run then succeeds with `{ version, winner, candidates }`, each candidate with its verdict, as `playbook:`; `version` and `winner` are null when none won, and the build ends there. ## Shadow and activation A build with a version moves to its shadow (`src/shadow`). The first version of a function, a function over an uploaded dataset and `shadow_periods: 0` pass at once, `shadow_passed` with no figures. Otherwise, at each of the `shadow_periods` closes of the grain after the version was built, or with `accumulates` of the enclosing period, its origin the last period that period holds and its target the periods to the end of the next, found by `periodsToTheEnd` (`src/shadow/shadow-schedule.ts`, `src/periods/enclosing.ts`), the due work writes the dataset's scoring rows at that close into the rows cache and walks the new version and the active one from that origin, one `shadow_predicted` a version with the series it predicted, `count`, and those its walk answered with no number or one that is not finite, `missing`, its predictions kept as content. Once a predicted period closes, it grades both against the dataset's rows over the series both versions predicted, `shadow_graded` with each metric, its pairs, the series it answered and those with no truth, `missing`, a stored prediction decoded so that one with no number is no prediction (`src/shadow/shadow-grades.ts`). Once every grade is in, `shadow_passed` or `shadow_failed` with the two versions' figures of the primary metric over those pairs, the new version passing when its figure is no higher than the active one's. A new version that answered nothing fails when the active one answered, and passes when only it answered; when neither has a graded pair, as before any truth arrives, the shadow is not decided and waits for one more close, up to `mostShadowPeriods`, 52, closes in all: a shadow that reaches them with no graded pair fails, `shadow_failed` with `ungraded_closes`, and the version is refused `shadow_failed`, so a target whose events stop does not keep both versions walking at every close and the build's row open. A verdict that arrives for a version a newer one superseded meanwhile records nothing, and the models projection takes the verdict of its challenger alone. Between closes the row rests until the next. -A version that passed activates by its rule (`src/shadow/model-lifecycle.ts`): `automatic` records `version_activated { rule: automatic }`; `{ approved: }` starts the `/approval` child with `{ function, version, card }` and records `version_activated { rule: approved, approved_by }` when it answers `approve`, or `activation_refused { because: approver_rejected }`, `unanswered` when it ends unanswered; a version that failed its shadow records `activation_refused { because: shadow_failed }`; and a version still in its shadow or awaiting its approval when a newer one is built records `activation_refused { because: superseded }`, its `/approval` child cancelled, and the newer version runs a shadow of its own. `activateVersion` (`src/models/activate-version.ts`) is the operation `activate_version`, `POST /definitions/prediction/{name}/activate` under `playbook:write` over HTTP alone, which records `version_activated { rule: requested }` for a built version whatever its shadow decided, `not_found` for a version not built and `conflict`, `already_active` for the active one; a requested activation of a version whose build had not settled settles it. +A version that passed activates by its rule (`src/shadow/model-lifecycle.ts`): `automatic` records `version_activated { rule: automatic }`; `{ approved: }` starts the `/approval` child with `{ function, version, card }` and records `version_activated { rule: approved, approved_by }` when it answers `approve`, or `activation_refused { because: approver_rejected, refused_by }`, `approved_by` and `refused_by` the caller who answered the approval, by `id` and `name`, as its answer holds it, `unanswered` when it ends unanswered; a version that failed its shadow records `activation_refused { because: shadow_failed }`; and a version still in its shadow or awaiting its approval when a newer one is built records `activation_refused { because: superseded }`, its `/approval` child cancelled, and the newer version runs a shadow of its own. `activateVersion` (`src/models/activate-version.ts`) is the operation `activate_version`, `POST /definitions/prediction/{name}/activate` under `playbook:write` over HTTP alone, which records `version_activated { rule: requested }` for a built version whatever its shadow decided, `not_found` for a version not built and `conflict`, `already_active` for the active one; a requested activation of a version whose build had not settled settles it. `predictionModels` (`src/models/models-projection.ts`) is the keyed projection `prediction_models`, version 1, over the `models` streams, one row a function: the `active` version with its data and file digests and the definition version it was built from, the `latest`, how many `versions`, the `challenger` in its shadow with its build, the `shadow_predicted` and `shadow_graded` counts and the `shadow` result. `get_definition`'s standing (`src/standing/prediction-standing.ts`, `model-standing.ts`) adds `models`, read from it, and `build`, the open build of the builds projection with its standing, `requested_at`, `expires_at` and its candidates uploaded and scored, beside the dataset. diff --git a/capabilities/prediction/measure/order-history.ts b/capabilities/prediction/measure/order-history.ts index a87991573..fefab8af9 100644 --- a/capabilities/prediction/measure/order-history.ts +++ b/capabilities/prediction/measure/order-history.ts @@ -93,7 +93,10 @@ export function savedIn(folders: ProjectedFolders, { name, source, document }: M [ { type: 'definition_created', - data: { content: { source, details: datasetDetailsOf(document) } }, + data: { + content: { source, details: datasetDetailsOf(document) }, + created_by: { id: 'acme-admin', name: 'acme-admin' }, + }, }, ], { diff --git a/capabilities/prediction/src/build-testing/playbook-calls.ts b/capabilities/prediction/src/build-testing/playbook-calls.ts index e4c8dbd92..f013c61fb 100644 --- a/capabilities/prediction/src/build-testing/playbook-calls.ts +++ b/capabilities/prediction/src/build-testing/playbook-calls.ts @@ -20,7 +20,13 @@ import type { ChildStart, ChildStarted } from '../capability/prediction-ports.ts const alpha = { org: 'acme', playbook: 'alpha' } as const; -const owner: CallerIdentity = { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }; +const owner: CallerIdentity = { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: allPermissions, + playbooks: '*', +}; interface HttpOperation { readonly registration: HttpRegistration<'playbook'>; diff --git a/capabilities/prediction/src/build-work/build-lifecycle.test.ts b/capabilities/prediction/src/build-work/build-lifecycle.test.ts index b7e6db34b..2b270d256 100644 --- a/capabilities/prediction/src/build-work/build-lifecycle.test.ts +++ b/capabilities/prediction/src/build-work/build-lifecycle.test.ts @@ -23,6 +23,8 @@ const alpha = { org: 'acme', playbook: 'alpha' }; const anInstant: unknown = expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/u); +const theTrainer = { id: 'acme-admin', name: 'acme-admin' }; + describe('a build of a prediction function over an uploaded dataset, answered through the inbox', () => { it('waits for its trainer, takes a candidate, scores it, builds and activates its first version, and answers a question', async () => { const playbook = buildPlaybook(); @@ -56,7 +58,7 @@ describe('a build of a prediction function over an uploaded dataset, answered th }); expect((await modelFactsOf(playbook))[0]).toMatchObject({ type: 'version_built', - data: { recipe_bytes: 22, limitations_bytes: 16, trained_by: 'acme-admin', rule: 'automatic' }, + data: { recipe_bytes: 22, limitations_bytes: 16, trained_by: theTrainer, rule: 'automatic' }, }); expect(await asked(playbook, { series: 'north', horizon: 1 })).toMatchObject({ output: { diff --git a/capabilities/prediction/src/dataset-testing/projected-datasets.ts b/capabilities/prediction/src/dataset-testing/projected-datasets.ts index ed29557f6..16aae5930 100644 --- a/capabilities/prediction/src/dataset-testing/projected-datasets.ts +++ b/capabilities/prediction/src/dataset-testing/projected-datasets.ts @@ -57,8 +57,11 @@ function definitionsOf(folders: ProjectedFolders): Pick { diff --git a/capabilities/prediction/src/facts/model-facts.ts b/capabilities/prediction/src/facts/model-facts.ts index 689ccbd49..48e17db4e 100644 --- a/capabilities/prediction/src/facts/model-facts.ts +++ b/capabilities/prediction/src/facts/model-facts.ts @@ -1,4 +1,4 @@ -import { DefinitionContextSchema, factOf, type Decided } from '@on.auto/operations'; +import { DefinitionContextSchema, RecordedCallerSchema, factOf, type Decided } from '@on.auto/operations'; import { Schema } from 'effect'; import { BaselineSchema } from '../document/evaluation-names.ts'; @@ -32,7 +32,7 @@ const RuleSchema = Schema.Literals(['automatic', 'approved']); const CardSchema = Schema.Struct({ version: Schema.Int, build: Schema.String, - trained_by: Schema.String, + trained_by: RecordedCallerSchema, dataset: CardDatasetSchema, file: Schema.Struct({ sha256: Schema.String, bytes: Schema.Int, opset: Schema.Int }), inputs: Schema.Array(TensorSchema), @@ -92,14 +92,14 @@ const ActivatedSchema = Schema.Struct({ data_sha256: Schema.String, file_sha256: Schema.String, rule: ActivationRuleSchema, - approved_by: Schema.optionalKey(Schema.String), + approved_by: Schema.optionalKey(RecordedCallerSchema), build: Schema.optionalKey(Schema.String), }); const RefusedSchema = Schema.Struct({ version: Schema.Int, because: Schema.Literals(['approver_rejected', 'unanswered', 'shadow_failed', 'superseded']), - refused_by: Schema.optionalKey(Schema.String), + refused_by: Schema.optionalKey(RecordedCallerSchema), build: Schema.String, }); diff --git a/capabilities/prediction/src/facts/prediction-facts.test.ts b/capabilities/prediction/src/facts/prediction-facts.test.ts index 59d5cac02..4adaa1839 100644 --- a/capabilities/prediction/src/facts/prediction-facts.test.ts +++ b/capabilities/prediction/src/facts/prediction-facts.test.ts @@ -74,7 +74,7 @@ const { training_bytes: _training, scoring_bytes: _scoring, ...cardDataset } = a const card = { version: 1, build: 'run-1', - trained_by: 'acme-trainer', + trained_by: { id: 'acme-trainer', name: 'acme-trainer' }, dataset: cardDataset, file: { sha256, bytes: 600, opset: 13 }, inputs: [], @@ -178,7 +178,7 @@ const modelFacts: readonly (readonly [string, Schema.JsonObject, string])[] = [ data_sha256: sha256, file_sha256: sha256, rule: 'approved', - approved_by: 'ada', + approved_by: { id: 'ada', name: 'ada' }, build: 'run-2', }, 'Version 2 of the model was activated as its approver approved', diff --git a/capabilities/prediction/src/models/activate-version.test.ts b/capabilities/prediction/src/models/activate-version.test.ts index 8e3bb9ced..aaf788379 100644 --- a/capabilities/prediction/src/models/activate-version.test.ts +++ b/capabilities/prediction/src/models/activate-version.test.ts @@ -7,6 +7,7 @@ import { activateVersion } from './activate-version.ts'; const signedInPerson: CallerIdentity = { id: 'person:user|ada', + name: 'Ada Lovelace', org: 'acme', permissions: allPermissions, playbooks: '*', diff --git a/capabilities/prediction/src/models/model-decider.test.ts b/capabilities/prediction/src/models/model-decider.test.ts index 26ac8242c..ba5faf7da 100644 --- a/capabilities/prediction/src/models/model-decider.test.ts +++ b/capabilities/prediction/src/models/model-decider.test.ts @@ -12,7 +12,7 @@ const acted = { at: '2026-10-12T09:00:00.000Z', by: 'playbook:alpha', name: 'dai const card = { build: 'build-1', - trained_by: 'acme-trainer', + trained_by: { id: 'acme-trainer', name: 'acme-trainer' }, dataset: { data_sha256: sha256, training_sha256: sha256, diff --git a/capabilities/prediction/src/scoring/version-card.ts b/capabilities/prediction/src/scoring/version-card.ts index 1686bba22..340f636c6 100644 --- a/capabilities/prediction/src/scoring/version-card.ts +++ b/capabilities/prediction/src/scoring/version-card.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer'; -import { textToKeep, type PlaybookAddress, type RecordedContent } from '@on.auto/operations'; +import { textToKeep, type PlaybookAddress, type RecordedCaller, type RecordedContent } from '@on.auto/operations'; import { Effect, Option } from 'effect'; import type { BuildState } from '../builds/build-state.ts'; @@ -11,7 +11,7 @@ import { featureNamesOf } from './scoring-inputs.ts'; export interface TrainerAnswer { readonly candidates: readonly string[]; - readonly by: string; + readonly by: RecordedCaller; readonly recipe?: string; readonly limitations?: string; } diff --git a/capabilities/prediction/src/shadow/approval.test.ts b/capabilities/prediction/src/shadow/approval.test.ts index a6e00f8e7..6d35d0d88 100644 --- a/capabilities/prediction/src/shadow/approval.test.ts +++ b/capabilities/prediction/src/shadow/approval.test.ts @@ -17,6 +17,7 @@ import { uploadedDailyVisits } from '../testing/daily-documents.ts'; const signedInPerson: CallerIdentity = { id: 'person:user|ada', + name: 'Ada Lovelace', org: 'acme', permissions: allPermissions, playbooks: '*', @@ -93,7 +94,7 @@ describe('a version whose activation is approved', () => { expect((await modelFactsOf(playbook)).at(-1)).toMatchObject({ type: 'version_activated', - data: { version: 1, rule: 'approved', approved_by: 'acme-admin', build }, + data: { version: 1, rule: 'approved', approved_by: { id: 'acme-admin', name: 'acme-admin' }, build }, }); expect(await runOf(playbook, build)).toMatchObject({ output: { status: 'succeeded', output: { version: 1 } } }); }); @@ -106,7 +107,7 @@ describe('a version whose activation is approved', () => { expect((await modelFactsOf(playbook)).at(-1)).toMatchObject({ type: 'activation_refused', - data: { version: 1, because: 'approver_rejected', refused_by: 'acme-admin', build }, + data: { version: 1, because: 'approver_rejected', refused_by: { id: 'acme-admin', name: 'acme-admin' }, build }, }); expect(await standingOf(playbook)).toMatchObject({ output: { standing: { models: { active: null, challenger: null, latest: 1 }, build: null } }, @@ -200,7 +201,7 @@ describe('a version a signed-in person approves', () => { expect((await modelFactsOf(playbook)).at(-1)).toMatchObject({ type: 'version_activated', - data: { rule: 'approved', approved_by: 'person:user|ada' }, + data: { rule: 'approved', approved_by: { id: 'person:user|ada', name: 'Ada Lovelace' } }, }); }); }); diff --git a/capabilities/prediction/src/shadow/model-lifecycle.ts b/capabilities/prediction/src/shadow/model-lifecycle.ts index a92bd18bd..6cffbbcdb 100644 --- a/capabilities/prediction/src/shadow/model-lifecycle.ts +++ b/capabilities/prediction/src/shadow/model-lifecycle.ts @@ -1,4 +1,5 @@ import type { RecordedRun } from '@on.auto/definitions'; +import type { RecordedCaller } from '@on.auto/operations'; import { Effect, Option, Schema } from 'effect'; import { restingUntil, type Performing } from '../build-work/build-performing.ts'; @@ -10,7 +11,12 @@ import { shadowStep } from './shadow-step.ts'; type Because = 'approver_rejected' | 'unanswered' | 'shadow_failed'; -function activated(performing: Performing, built: BuiltVersion, rule: 'automatic' | 'approved', by: string | null) { +function activated( + performing: Performing, + built: BuiltVersion, + rule: 'automatic' | 'approved', + by: RecordedCaller | null, +) { return executed(performing, (asked) => ({ ...asked, type: 'activate', @@ -24,7 +30,7 @@ function activated(performing: Performing, built: BuiltVersion, rule: 'automatic })); } -function refused(performing: Performing, built: BuiltVersion, because: Because, by: string | null) { +function refused(performing: Performing, built: BuiltVersion, because: Because, by: RecordedCaller | null) { return executed(performing, (asked) => ({ ...asked, type: 'refuse', @@ -63,7 +69,7 @@ const decodeDecision = Schema.decodeUnknownOption(Schema.Struct({ decision: Sche interface Decided { readonly decision: 'approve' | 'reject' | 'unanswered'; - readonly by: string | null; + readonly by: RecordedCaller | null; } function decidedIn({ run, answer }: RecordedRun): Decided { diff --git a/capabilities/prediction/src/testing/cancels-in-a-call.ts b/capabilities/prediction/src/testing/cancels-in-a-call.ts index e20c845bf..b5c062e3b 100644 --- a/capabilities/prediction/src/testing/cancels-in-a-call.ts +++ b/capabilities/prediction/src/testing/cancels-in-a-call.ts @@ -20,7 +20,13 @@ export interface CancelsInACall { readonly cancelled: () => readonly Outcome[]; } -const caller = { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' } as const; +const caller = { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: allPermissions, + playbooks: '*', +} as const; function isAFinish(command: unknown): boolean { return Predicate.hasProperty(command, 'type') && command.type === 'finish'; diff --git a/capabilities/prediction/src/testing/prediction-runs.ts b/capabilities/prediction/src/testing/prediction-runs.ts index 04699a889..75956a8c9 100644 --- a/capabilities/prediction/src/testing/prediction-runs.ts +++ b/capabilities/prediction/src/testing/prediction-runs.ts @@ -40,7 +40,7 @@ const forecastRun: RunContext = { id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', org: 'acme', playbook: 'alpha', - caller: { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, + caller: { id: 'acme-admin', name: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, definition: { name: 'weekly-orders', version: 1 }, journal: recordingJournal(), lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, diff --git a/capabilities/prediction/src/testing/upload-calls.ts b/capabilities/prediction/src/testing/upload-calls.ts index ef99c9874..dcc42e690 100644 --- a/capabilities/prediction/src/testing/upload-calls.ts +++ b/capabilities/prediction/src/testing/upload-calls.ts @@ -52,7 +52,13 @@ export function failingAfter(text: string): StreamedContent { return { stream, bytes: 1000, mediaType: 'text/csv' }; } -const acmeAdmin = { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' } as const; +const acmeAdmin = { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: allPermissions, + playbooks: '*', +} as const; export function uploading(content: RecordedContent = memoryRecordedContent()): Uploading { const operation = uploadDataset(content); diff --git a/capabilities/reasoning/src/catalog/list-models.test.ts b/capabilities/reasoning/src/catalog/list-models.test.ts index 44e54793c..050c0261e 100644 --- a/capabilities/reasoning/src/catalog/list-models.test.ts +++ b/capabilities/reasoning/src/catalog/list-models.test.ts @@ -8,9 +8,21 @@ import { jsonResponse } from '../provider-testing/recording-fetch.ts'; import { catalogFor } from '../testing/catalog-harness.ts'; import { defineListModels } from './list-models.ts'; -const reader: CallerIdentity = { id: 'acme-reader', org: 'acme', permissions: ['org:read'], playbooks: '*' }; +const reader: CallerIdentity = { + id: 'acme-reader', + name: 'acme-reader', + org: 'acme', + permissions: ['org:read'], + playbooks: '*', +}; -const writerOnly: CallerIdentity = { id: 'acme-writer', org: 'acme', permissions: ['playbook:write'], playbooks: '*' }; +const writerOnly: CallerIdentity = { + id: 'acme-writer', + name: 'acme-writer', + org: 'acme', + permissions: ['playbook:write'], + playbooks: '*', +}; const environment = { ANTHROPIC_API_KEY: 'sk-ant-key', diff --git a/capabilities/reasoning/src/testing/reasoning-runs.ts b/capabilities/reasoning/src/testing/reasoning-runs.ts index c1a549fac..41dceb9f8 100644 --- a/capabilities/reasoning/src/testing/reasoning-runs.ts +++ b/capabilities/reasoning/src/testing/reasoning-runs.ts @@ -50,7 +50,7 @@ export function runContextWith(journal: RecordingJournal): RunContext { id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', org: 'acme', playbook: 'alpha', - caller: { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, + caller: { id: 'acme-admin', name: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, definition: { name: 'summary', version: 1 }, journal, lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, diff --git a/capabilities/recall/src/testing/recall-runs.ts b/capabilities/recall/src/testing/recall-runs.ts index 079cd167d..d928814f2 100644 --- a/capabilities/recall/src/testing/recall-runs.ts +++ b/capabilities/recall/src/testing/recall-runs.ts @@ -27,7 +27,7 @@ const reviewsRun: RunContext = { id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', org: 'acme', playbook: 'alpha', - caller: { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, + caller: { id: 'acme-admin', name: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }, definition: { name: 'reviews', version: 1 }, journal: recordingJournal(), lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, diff --git a/docs/assets/openapi.json b/docs/assets/openapi.json index 90eb7d47a..218ec8582 100644 --- a/docs/assets/openapi.json +++ b/docs/assets/openapi.json @@ -4,7 +4,7 @@ "info": { "title": "Auto Runtime", "version": "0.23.0", - "description": "Whatever the person wants done that should keep working after this conversation, however they put it and in any line of business, is a playbook on this server, not a document, a plan, a note or a one-off answer. Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run directly or in workflows; runs belong to the playbook. Before writing a definition, read its format with get_guide, which also holds what these words mean and the recipes for common tasks. Every operation answers a problem document when it does not succeed, and says in its description what it does, when to use it and which operation to use instead." + "description": "Whatever the person wants done that keeps working after this conversation, however they put it and in any line of business, is a playbook here, not a document, plan, note or one-off answer. Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run alone or in workflows; runs belong to the playbook. Before writing a definition, read its format with get_guide, which also holds what these words mean and the recipes for common tasks. Every operation answers a problem document when it does not succeed, and says in its description what it does, when to use it and which operation to use instead." }, "servers": [ { @@ -146,8 +146,23 @@ "description": "When the playbook was created, in ISO 8601 UTC" }, "created_by": { - "type": "string", - "description": "The id of the caller who created the playbook" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who created the playbook, by id and name" }, "updated_at": { "type": "string", @@ -290,8 +305,23 @@ "description": "When the playbook was created, in ISO 8601 UTC" }, "created_by": { - "type": "string", - "description": "The id of the caller who created the playbook" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who created the playbook, by id and name" }, "updated_at": { "type": "string", @@ -426,8 +456,23 @@ "description": "When the playbook was created, in ISO 8601 UTC" }, "created_by": { - "type": "string", - "description": "The id of the caller who created the playbook" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who created the playbook, by id and name" }, "updated_at": { "type": "string", @@ -583,8 +628,23 @@ "description": "When the playbook was created, in ISO 8601 UTC" }, "created_by": { - "type": "string", - "description": "The id of the caller who created the playbook" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who created the playbook, by id and name" }, "updated_at": { "type": "string", @@ -729,8 +789,23 @@ "description": "When the playbook was created, in ISO 8601 UTC" }, "created_by": { - "type": "string", - "description": "The id of the caller who created the playbook" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who created the playbook, by id and name" }, "updated_at": { "type": "string", @@ -1689,8 +1764,23 @@ "description": "pending until it is approved and its credential given, connected, broken or removed" }, "added_by": { - "type": "string", - "description": "The id of the caller who added it" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who added it, by id and name" }, "added_at": { "type": "string", @@ -1710,13 +1800,28 @@ "connected_by": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false }, { "type": "null" } ], - "description": "The id of the caller who connected it, approving it or giving its credential" + "description": "The caller who connected it, approving it or giving its credential, by id and name" }, "granted_scope": { "anyOf": [ @@ -2090,8 +2195,23 @@ "description": "pending until it is approved and its credential given, connected, broken or removed" }, "added_by": { - "type": "string", - "description": "The id of the caller who added it" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who added it, by id and name" }, "added_at": { "type": "string", @@ -2111,13 +2231,28 @@ "connected_by": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false }, { "type": "null" } ], - "description": "The id of the caller who connected it, approving it or giving its credential" + "description": "The caller who connected it, approving it or giving its credential, by id and name" }, "granted_scope": { "anyOf": [ @@ -2459,8 +2594,23 @@ "description": "pending until it is approved and its credential given, connected, broken or removed" }, "added_by": { - "type": "string", - "description": "The id of the caller who added it" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who added it, by id and name" }, "added_at": { "type": "string", @@ -2480,13 +2630,28 @@ "connected_by": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false }, { "type": "null" } ], - "description": "The id of the caller who connected it, approving it or giving its credential" + "description": "The caller who connected it, approving it or giving its credential, by id and name" }, "granted_scope": { "anyOf": [ @@ -2824,8 +2989,23 @@ "description": "pending until it is approved and its credential given, connected, broken or removed" }, "added_by": { - "type": "string", - "description": "The id of the caller who added it" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who added it, by id and name" }, "added_at": { "type": "string", @@ -2845,13 +3025,28 @@ "connected_by": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false }, { "type": "null" } ], - "description": "The id of the caller who connected it, approving it or giving its credential" + "description": "The caller who connected it, approving it or giving its credential, by id and name" }, "granted_scope": { "anyOf": [ @@ -3205,8 +3400,23 @@ "description": "pending until it is approved and its credential given, connected, broken or removed" }, "added_by": { - "type": "string", - "description": "The id of the caller who added it" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who added it, by id and name" }, "added_at": { "type": "string", @@ -3226,13 +3436,28 @@ "connected_by": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false }, { "type": "null" } ], - "description": "The id of the caller who connected it, approving it or giving its credential" + "description": "The caller who connected it, approving it or giving its credential, by id and name" }, "granted_scope": { "anyOf": [ @@ -3654,8 +3879,23 @@ "description": "pending until it is approved and its credential given, connected, broken or removed" }, "added_by": { - "type": "string", - "description": "The id of the caller who added it" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who added it, by id and name" }, "added_at": { "type": "string", @@ -3675,13 +3915,28 @@ "connected_by": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false }, { "type": "null" } ], - "description": "The id of the caller who connected it, approving it or giving its credential" + "description": "The caller who connected it, approving it or giving its credential, by id and name" }, "granted_scope": { "anyOf": [ @@ -4087,8 +4342,23 @@ "description": "pending until it is approved and its credential given, connected, broken or removed" }, "added_by": { - "type": "string", - "description": "The id of the caller who added it" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who added it, by id and name" }, "added_at": { "type": "string", @@ -4108,13 +4378,28 @@ "connected_by": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false }, { "type": "null" } ], - "description": "The id of the caller who connected it, approving it or giving its credential" + "description": "The caller who connected it, approving it or giving its credential, by id and name" }, "granted_scope": { "anyOf": [ @@ -4704,8 +4989,23 @@ "description": "pending until it is approved and its credential given, connected, broken or removed" }, "added_by": { - "type": "string", - "description": "The id of the caller who added it" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who added it, by id and name" }, "added_at": { "type": "string", @@ -4725,13 +5025,28 @@ "connected_by": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false }, { "type": "null" } ], - "description": "The id of the caller who connected it, approving it or giving its credential" + "description": "The caller who connected it, approving it or giving its credential, by id and name" }, "granted_scope": { "anyOf": [ @@ -5340,8 +5655,23 @@ "description": "When the definition was created, in ISO 8601 UTC" }, "created_by": { - "type": "string", - "description": "The id of the caller who created the definition" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who created the definition, by id and name" }, "updated_at": { "type": "string", @@ -5749,8 +6079,23 @@ "description": "When the definition was created, in ISO 8601 UTC" }, "created_by": { - "type": "string", - "description": "The id of the caller who created the definition" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who created the definition, by id and name" }, "updated_at": { "type": "string", @@ -6042,8 +6387,23 @@ "description": "When the definition was created, in ISO 8601 UTC" }, "created_by": { - "type": "string", - "description": "The id of the caller who created the definition" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who created the definition, by id and name" }, "updated_at": { "type": "string", @@ -6463,8 +6823,23 @@ "description": "When the definition was created, in ISO 8601 UTC" }, "created_by": { - "type": "string", - "description": "The id of the caller who created the definition" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who created the definition, by id and name" }, "updated_at": { "type": "string", @@ -6874,8 +7249,23 @@ "description": "When the definition was created, in ISO 8601 UTC" }, "created_by": { - "type": "string", - "description": "The id of the caller who created the definition" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who created the definition, by id and name" }, "updated_at": { "type": "string", @@ -7427,8 +7817,23 @@ "description": "When the run started, in ISO 8601 UTC" }, "started_by": { - "type": "string", - "description": "The id of the caller who started the run" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who started the run, by id and name" }, "finished_at": { "type": "string", @@ -7839,8 +8244,23 @@ "description": "When the run started, in ISO 8601 UTC" }, "started_by": { - "type": "string", - "description": "The id of the caller who started the run" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who started the run, by id and name" }, "finished_at": { "type": "string", @@ -8266,8 +8686,23 @@ "description": "When the run started, in ISO 8601 UTC" }, "started_by": { - "type": "string", - "description": "The id of the caller who started the run" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who started the run, by id and name" }, "finished_at": { "type": "string", @@ -8498,8 +8933,23 @@ "description": "When the run started, in ISO 8601 UTC" }, "started_by": { - "type": "string", - "description": "The id of the caller who started the run" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who started the run, by id and name" }, "finished_at": { "type": "string", @@ -11257,8 +11707,23 @@ "description": "The reply that brought the answer, when a reply did" }, "answered_by": { - "type": "string", - "description": "The id of the caller the answer came from" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the caller" + }, + "name": { + "type": "string", + "description": "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "description": "The caller who answered it, by id and name" }, "answered_at": { "type": "string", diff --git a/docs/engineering/reference/mcp.md b/docs/engineering/reference/mcp.md index 7776e5ec2..7e6f48df2 100644 --- a/docs/engineering/reference/mcp.md +++ b/docs/engineering/reference/mcp.md @@ -18,7 +18,7 @@ The server is also an [MCP](https://modelcontextprotocol.io) server, so an agent Most MCP clients take an entry of this shape. In Claude Code, `claude mcp add --transport http auto-runtime http://localhost:5555/mcp --header "Authorization: Bearer "` adds the same. In [local mode](../../self-host/security.md#local-mode), leave out the header. Where `PUBLIC_URL` turns sign-in on, a client that runs the MCP authorization flow connects without the header and signs the person in through the Auto identity service, as the [security guide](../../self-host/security.md#what-signed-in-people-can-do) describes. -`/mcp` serves every tool on one connection, so an agent can create a playbook and work in it at once. The org is the key's own, since a key belongs to one org, or, for a signed-in person, the org their token names, and never an argument; in local mode, where no key names one, it is `local`, so the playbooks an agent creates there are the ones `GET /v1/orgs/local/playbooks` lists. The org id `local` is reserved for local mode: the key command refuses it, and an `API_KEYS` entry with it stops the server at start-up, so no key can reach the playbooks made in local mode. The playbook tools (`create_playbook`, `list_playbooks`, `update_playbook` and `retire_playbook`), `list_models` and the connection tools (`add_connection`, `list_connections` and `remove_connection`) are as on HTTP; the five routes of Studio's page that connects a connection are HTTP alone, and so are `list_providers`, which a page reads, `get_playbook`, `get_connection`, `get_run_history`, `get_playbook_analytics` and `validate`, since `list_playbooks`, `list_connections`, `list_playbook_events` given a `run_id` and `dry_run` answer what an agent needs of them and Studio reads the rest. Every tool that works inside a playbook (`create_definition`, `list_definitions`, `get_definition`, `update_definition`, `retire_definition`, `run_definition`, `get_run`, `cancel_run`, `list_runs`, `get_event`, `list_playbook_events`, `publish_event`, `introspect`, `dry_run`, `execute`, `list_interactions`, `answer_interaction` and `send_run_event`) takes the playbook's id as a required `playbook` argument, `search` takes it as an optional one, and `get_guide` reads the guides on every endpoint: twenty-eight tools for a key that may call them all. `search` answers the tool servers the playbook may use and their tools, which an agent names in a reasoning function's `tools`, found by the words of `terms` where it gives them, and without `playbook` every server set up for the org, each with the playbooks it serves, so an agent sees what is set up before it makes a playbook, and, to a caller who may add a connection, `providers`, the providers a person may connect that no server serves yet, so an agent asked for a service no server serves can offer to connect one; it is the org's operation, `GET /v1/orgs/{org}/tool-servers/search`, which the org endpoint serves too; `introspect` reads one tool whole and `dry_run` answers what `execute` would check, its arguments against the tool's input schema among it, the three reading and calling nothing, and `execute` calls one tool as a run would and answers what the run's model would see, under `playbook:write` whatever the tool does, recorded in the playbook's history and never a run; the [HTTP reference](../../reference/http.md#tool-servers) describes them. `list_runs` and `list_playbook_events` page with `limit` and `cursor`, answer `has_more` and `next_cursor`, and read a retired playbook, and `get_event` reads one event whole, each field the 2 KiB rule of a page names, an input, output, record, answer, arguments, result or event data, whole within 64 KiB and its size beyond, since a tool result carries its output twice; the [HTTP reference](../../reference/http.md#run-history-and-playbook-events) describes their fields. A connection lists the tools its key may call, so a read-only key is offered the queries alone, and a key that may only read inside some playbooks is offered `list_playbooks` too, which lists those playbooks. Every endpoint gives a connecting agent instructions, `instructionsFor` of `@on.auto/api`, built per request for the tools listed and the definition types the server runs: first, that whatever the person wants done that should keep working after the conversation, however they put it and in any line of business, is a playbook on this server and not a document, a plan, a note or a one-off answer, then what a playbook is and a sentence for each type it runs, that what the person wants kept goes in a recall function and not in the agent's own notes where the server runs recall functions, what the connection acts on, where the formats are and that each definition is given an overview, where `list_models` is listed and `search` is not that it lists the models a reasoning function names, where `search` and `add_connection` are both listed that a service a playbook will use is connected on this server, whatever connector the agent has of its own, by `search` with the service's words and `add_connection` with the name of a provider that matches, and that a service cannot be connected only when no provider or server matches, that an answer to what a run waits on goes to its request through `answer_interaction` and no new run, what to say of what was done and what the person can do next, in which words, and what a refusal says. The descriptions carry what the instructions leave out: `get_guide`'s names the recipes whose tools the connection lists, `run_definition`'s says that a run that finishes later answers started and `get_run` shows how it ended, `list_models`'s and `search`'s say how a reasoning function names its model and its tools, as the guide of its format does, `execute`'s and `dry_run`'s say what each does, and those of `list_runs`, `list_playbook_events` and `list_interactions` the paging rule. With every tool the instructions take 1,993 characters on `/mcp`, 1,993 on `/orgs/{org}/mcp`, where `search` and `add_connection` are listed, and 1,575 on `/orgs/{org}/playbooks/{playbook}/mcp`, under a bound of 2,000, the most Claude Code shows of a server's instructions, as `packages/api/src/mcp/tool-calling-instructions.test.ts` pins them. `search`'s description takes 454 of the 800 characters a description may, as `packages/server/src/mcp/served-tools.test.ts` pins it. The listing of the tools takes 43,845 bytes on the wire on `/mcp`, 11,975 on the org endpoint and 31,796 on the playbook endpoint, as `packages/server/src/mcp/served-schemas.test.ts` pins them, under the 48,000, 13,500 and 40,000 the server checks when it starts, and a connection lists at most 32 tools. `get_guide`, the resources `guide://` and the prompts `first-playbook`, `remember`, `give-tools` and `schedule`, each offered where the connection lists the tools its steps call, serve the terminology, the reference page of each format and the recipes; the [API README](../../../packages/api/README.md#over-mcp) describes them, the annotations of each tool and the bounds. +`/mcp` serves every tool on one connection, so an agent can create a playbook and work in it at once. The org is the key's own, since a key belongs to one org, or, for a signed-in person, the org their token names, and never an argument; in local mode, where no key names one, it is `local`, so the playbooks an agent creates there are the ones `GET /v1/orgs/local/playbooks` lists. The org id `local` is reserved for local mode: the key command refuses it, and an `API_KEYS` entry with it stops the server at start-up, so no key can reach the playbooks made in local mode. The playbook tools (`create_playbook`, `list_playbooks`, `update_playbook` and `retire_playbook`), `list_models` and the connection tools (`add_connection`, `list_connections` and `remove_connection`) are as on HTTP; the five routes of Studio's page that connects a connection are HTTP alone, and so are `list_providers`, which a page reads, `get_playbook`, `get_connection`, `get_run_history`, `get_playbook_analytics` and `validate`, since `list_playbooks`, `list_connections`, `list_playbook_events` given a `run_id` and `dry_run` answer what an agent needs of them and Studio reads the rest. Every tool that works inside a playbook (`create_definition`, `list_definitions`, `get_definition`, `update_definition`, `retire_definition`, `run_definition`, `get_run`, `cancel_run`, `list_runs`, `get_event`, `list_playbook_events`, `publish_event`, `introspect`, `dry_run`, `execute`, `list_interactions`, `answer_interaction` and `send_run_event`) takes the playbook's id as a required `playbook` argument, `search` takes it as an optional one, and `get_guide` reads the guides on every endpoint: twenty-eight tools for a key that may call them all. `search` answers the tool servers the playbook may use and their tools, which an agent names in a reasoning function's `tools`, found by the words of `terms` where it gives them, and without `playbook` every server set up for the org, each with the playbooks it serves, so an agent sees what is set up before it makes a playbook, and, to a caller who may add a connection, `providers`, the providers a person may connect that no server serves yet, so an agent asked for a service no server serves can offer to connect one; it is the org's operation, `GET /v1/orgs/{org}/tool-servers/search`, which the org endpoint serves too; `introspect` reads one tool whole and `dry_run` answers what `execute` would check, its arguments against the tool's input schema among it, the three reading and calling nothing, and `execute` calls one tool as a run would and answers what the run's model would see, under `playbook:write` whatever the tool does, recorded in the playbook's history and never a run; the [HTTP reference](../../reference/http.md#tool-servers) describes them. `list_runs` and `list_playbook_events` page with `limit` and `cursor`, answer `has_more` and `next_cursor`, and read a retired playbook, and `get_event` reads one event whole, each field the 2 KiB rule of a page names, an input, output, record, answer, arguments, result or event data, whole within 64 KiB and its size beyond, since a tool result carries its output twice; the [HTTP reference](../../reference/http.md#run-history-and-playbook-events) describes their fields. A connection lists the tools its key may call, so a read-only key is offered the queries alone, and a key that may only read inside some playbooks is offered `list_playbooks` too, which lists those playbooks. Every endpoint gives a connecting agent instructions, `instructionsActingAs` of `@on.auto/api`, built per request for its caller, the tools listed and the definition types the server runs: first, whom the connection acts as, by name and id, and in which org, then that whatever the person wants done that keeps working after the conversation, however they put it and in any line of business, is a playbook here and not a document, a plan, a note or a one-off answer, then what a playbook is and a sentence for each type it runs, that what the person wants kept goes in a recall function and not in the agent's own notes where the server runs recall functions, what the connection acts on, where the formats are and that each definition is given an overview, where `list_models` is listed and `search` is not that it lists the models a reasoning function names, where `search` and `add_connection` are both listed that a service a playbook will use is connected on this server, whatever connector the agent has of its own, by `search` with the service's words and `add_connection` with the name of a provider that matches, and that a service cannot be connected only when no provider or server matches, that an answer to what a run waits on goes to its request through `answer_interaction` and no new run, what to say of what was done and what the person can do next, in which words, and what a refusal says. The descriptions carry what the instructions leave out: `get_guide`'s names the recipes whose tools the connection lists, `run_definition`'s says that a run that finishes later answers started and `get_run` shows how it ended, `list_models`'s and `search`'s say how a reasoning function names its model and its tools, as the guide of its format does, `execute`'s and `dry_run`'s say what each does, and those of `list_runs`, `list_playbook_events` and `list_interactions` the paging rule. With every tool the instructions take 1,869 characters on `/mcp`, 1,866 on `/orgs/{org}/mcp`, where `search` and `add_connection` are listed, and 1,502 on `/orgs/{org}/playbooks/{playbook}/mcp` after their opening line, and 1,999, 1,996 and 1,632 with it for a person named in 20 characters whose user id takes 32 and whose org id 30, under a bound of 2,000 over the whole text, the most Claude Code shows of a server's instructions, as `packages/api/src/mcp/tool-calling-instructions.test.ts` pins them. `search`'s description takes 454 of the 800 characters a description may, as `packages/server/src/mcp/served-tools.test.ts` pins it. The listing of the tools takes 43,845 bytes on the wire on `/mcp`, 11,975 on the org endpoint and 31,796 on the playbook endpoint, as `packages/server/src/mcp/served-schemas.test.ts` pins them, under the 48,000, 13,500 and 40,000 the server checks when it starts, and a connection lists at most 32 tools. `get_guide`, the resources `guide://` and the prompts `first-playbook`, `remember`, `give-tools` and `schedule`, each offered where the connection lists the tools its steps call, serve the terminology, the reference page of each format and the recipes; the [API README](../../../packages/api/README.md#over-mcp) describes them, the annotations of each tool and the bounds. | Endpoint | Tools | Use it | | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | diff --git a/docs/engineering/reference/reasoning-format.md b/docs/engineering/reference/reasoning-format.md index 88b4c0700..1cabd5456 100644 --- a/docs/engineering/reference/reasoning-format.md +++ b/docs/engineering/reference/reasoning-format.md @@ -234,7 +234,7 @@ curl --request POST http://localhost:5555/v1/orgs/acme/playbooks/sales/definitio "status": "succeeded", "output": "Globex renewed for two years and expanded to three regions this quarter.", "started_at": "2026-10-01T09:30:00.000Z", - "started_by": "local", + "started_by": { "id": "local", "name": "ada" }, "finished_at": "2026-10-01T09:30:02.412Z" } ``` diff --git a/docs/guides/triggers.md b/docs/guides/triggers.md index ca96c9486..d7c97c4cf 100644 --- a/docs/guides/triggers.md +++ b/docs/guides/triggers.md @@ -38,7 +38,7 @@ No trigger has an input of its own. A run that a schedule starts gets only `{ "s - A workflow started by an event takes its data from the event. `input.from` shapes the run's input before the first step. - A workflow that your agent also runs tells the two apart in `input.from`, as steps 1 and 6 show. -A run that a trigger starts acts as the playbook itself, not as you. Its `started_by` is `playbook:` followed by the playbook's id, and its steps can read and write that playbook and nothing else. It is kept in the playbook's history like any other run. +A run that a trigger starts acts as the playbook itself, not as you. Its `started_by` names the playbook: its `id` is `playbook:` followed by the playbook's id, and its `name` the playbook's id, and its steps can read and write that playbook and nothing else. It is kept in the playbook's history like any other run. ### Overlap and cost @@ -124,7 +124,7 @@ the "" workflow, show who started it, its status and its history. Check that: -- the run was started by the playbook itself: its `started_by` is `playbook:` followed by your playbook's id; +- the run was started by the playbook itself: the `id` of its `started_by` is `playbook:` followed by your playbook's id; - the first event of its history, `run_started`, reads `A run of the workflow “” was started by its every schedule.`, with the name your workflow is saved under; - it ended as the test in step 2 did, with the output you expect. @@ -198,7 +198,7 @@ history. Check that: - the agent reports the event's `id`, `time` and `recorded_at`; -- a new run appears within a few seconds, started by the playbook itself (`started_by` is `playbook:` followed by your playbook's id); +- a new run appears within a few seconds, started by the playbook itself (the `id` of its `started_by` is `playbook:` followed by your playbook's id); - the first event of its history, `run_started`, reads `A run of the workflow “” was started by its event trigger.` A filter matches more than published events. It also matches an event another workflow emits, or one of the playbook's own facts, such as `run_succeeded` with the `subject` `reasoning/` when that function's run succeeds. Events recorded before the trigger was saved never start it. diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index d2e2df31e..b853bb942 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -56,6 +56,7 @@ The runtime accepts a token only when it meets every rule: | `exp` | Present and not past, with 60 seconds of leeway | | `nbf` and `iat` | Not ahead, with 60 seconds of leeway | | `sub` | A non-empty string of at most 256 bytes, without control characters, unpaired surrogates or noncharacters. These are the bounds of a name in the facts, since `sub` becomes part of the name recorded for the person. For a token that names no org, an org id other than `local` too, since it is the org the person acts in | +| `name` | Present, and a non-empty string of at most 256 bytes, without control characters, unpaired surrogates or noncharacters. It is the person's name as the identity service gives it, which the runtime records beside their id as who did what they did, and names in the instructions of an MCP connection | | `org_id` | For an access token that names an org: present, and an org id of 1 to 64 letters, digits, `_` and `-`, other than `local`. It is the id of the organization the person signed in to. The identity service puts it in the token when the person grants `user:org:read` | | `o` | For a session that names an org: an object whose `id` is an org id under the same rule. Version 2 of the service's session token names the organization in `o`, beside its slug, the person's role, their permissions and a feature-permission map. The runtime reads only the `id` | | `scope` | For an access token that holds it, a string of scopes separated by spaces, as the identity service issues it. The `scope` of a session is not read | @@ -77,17 +78,19 @@ A key set that `IDENTITY_JWKS` gives is read from nowhere: its keys are the ones A token that fails any rule is refused `401` with `invalid_token`, and so is an opaque token. The problem's `detail` names the rule or the claim the token failed, and never a value it holds: -| Token | Detail | -| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| An expired token | `The token was refused: ERR_JWT_EXPIRED on its exp claim` | -| An access token without `org_id` that holds an `o` | `The token was refused: ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim` | -| An access token whose `org_id` is not an org id other than `local` | `The token was refused: its org_id claim is not an org id other than local` | -| A session without `o` that holds an `org_id` | `The token was refused: ERR_JWT_CLAIM_VALIDATION_FAILED on its o claim` | -| A session whose `o` is no object, or holds no `id` that is an org id other than `local` | `The token was refused: its o.id claim is not an org id other than local` | -| A token that names no org, whose `sub` is not an org id other than `local` | `The token was refused: its sub claim is not an org id other than local` | -| An access token for another resource | Ends `on its aud claim` | -| A session given to a page at an origin the runtime does not allow, or naming no origin | Ends `on its azp claim` | -| A session sent without an `Origin` header, or from another origin than its `azp` | `The token was refused: the session was not presented by the page it was issued to` | +| Token | Detail | +| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| An expired token | `The token was refused: ERR_JWT_EXPIRED on its exp claim` | +| An access token without `org_id` that holds an `o` | `The token was refused: ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim` | +| An access token whose `org_id` is not an org id other than `local` | `The token was refused: its org_id claim is not an org id other than local` | +| A session without `o` that holds an `org_id` | `The token was refused: ERR_JWT_CLAIM_VALIDATION_FAILED on its o claim` | +| A session whose `o` is no object, or holds no `id` that is an org id other than `local` | `The token was refused: its o.id claim is not an org id other than local` | +| A token that names no org, whose `sub` is not an org id other than `local` | `The token was refused: its sub claim is not an org id other than local` | +| A token without `name` | `The token was refused: ERR_JWT_CLAIM_VALIDATION_FAILED on its name claim` | +| A token whose `name` is empty, longer than 256 bytes or holds a control character | `The token was refused: its name is not a non-empty string of at most 256 bytes without control characters, unpaired surrogates or noncharacters` | +| An access token for another resource | Ends `on its aud claim` | +| A session given to a page at an origin the runtime does not allow, or naming no origin | Ends `on its azp claim` | +| A session sent without an `Origin` header, or from another origin than its `azp` | `The token was refused: the session was not presented by the page it was issued to` | ## The audience of a token @@ -167,10 +170,10 @@ API keys cover what sign-in does not: a machine, a pipeline or a trainer's agent ## The name recorded for a signed-in person -A signed-in person acts under the name `person:`, where the subject is the token's `sub`, unchanged. This name lets a reader of the facts tell a person from a key. +A signed-in person acts under the id `person:`, where the subject is the token's `sub`, unchanged, and the name the token's `name` claim gives. The id lets a reader of the facts tell a person from a key. -- It is the `by` of every fact the person records, including the activation of a prediction function version they ask for. -- It is the `created_by`, `started_by`, `answered_by` and `approved_by` of what they create, start, answer and approve. +- The id is the `by` of every fact the person records, including the activation of a prediction function version they ask for. +- The id and the name, as `{ id, name }`, are the `created_by` of a playbook and a definition they create, the `added_by` and the `connected_by` of a connection they add and connect, the `started_by` of a run they start, the `answered_by` of a request they answer, and the `trained_by`, `approved_by` and `refused_by` of a version of a prediction function they train, approve or refuse. - A run event the person sends without a `source` gets the source `/callers/` followed by that name, percent-encoded as one path segment. ## What happens when access is revoked diff --git a/docs/reference/http.md b/docs/reference/http.md index 939aa71b8..88a499213 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -90,7 +90,7 @@ These routes are relative to `/v1/orgs/{org}`: | `name` | 1 to 100 characters, stored without the whitespace around it; it cannot be all whitespace | | `description` | At most 2,000 characters, stored without the whitespace around it | -Each operation returns the playbook, with its `id`, `name`, `description`, `status` (`active` or `retired`), `created_at`, `created_by`, `updated_at` and, once retired, `retired_at`. `list_playbooks` returns them in `playbooks`. +Each operation returns the playbook, with its `id`, `name`, `description`, `status` (`active` or `retired`), `created_at`, `created_by`, the caller who created it, by `id` and `name`, `updated_at` and, once retired, `retired_at`. `list_playbooks` returns them in `playbooks`. - `list_playbooks` lists the playbooks the caller may access. It leaves out retired ones unless `include_retired` asks for them. It also accepts a key with only `playbook:read`, so a key that may only read inside some playbooks can find them. - A key limited to a list of playbooks can create only a playbook whose id is on that list. @@ -137,6 +137,8 @@ The `source` is a [reasoning function document](reasoning-format.md). These rule - A retired definition can be read, but cannot be edited or run. - A definition carries its `input_schema` and `output_schema` where its document declares them. A reasoning function, a computation function or a workflow whose `output.shape` names an [output shape](output-shapes.md) has the shape's schema as its `output_schema`, and the shape's name as `details.shape`, such as `"details": { "shape": "series" }`; `details` is left out otherwise. A prediction function has the `series` shape so, since its questions answer a series. `list_definitions` shows both too. +A definition names the caller who created it as `created_by`, by `id` and `name`, and keeps it across its versions. + `create_definition`, `update_definition`, `get_definition` and `retire_definition` return the definition with its `overview` and its `used_by`; `list_definitions` leaves both out. - `overview` is Markdown its author gives beside `source` for the person who runs the business: why the definition exists, what it does and how it fits, in one to three short paragraphs, with a Mermaid diagram in a fenced block where a flow is worth a picture. It holds 1 character to 16,384 UTF-8 bytes. The version keeps it as written, and an update that leaves it out keeps the overview saved. A definition saved without one has none. @@ -225,15 +227,15 @@ Each request has these fields: It records the answer on the run as one fact, `request_answered`, and returns that fact: -| Field | Contents | -| ------------- | ------------------------------------------------------------ | -| `run_id` | The run that asked | -| `answer_id` | The id of the fact | -| `answer` | The answer | -| `claimed_for` | Whom the caller says it is answering for, when given | -| `answered_by` | The caller's id; for a run answered by a reply, the playbook | -| `reply` | For a run answered by a reply, that reply's identity | -| `answered_at` | When the answer was recorded | +| Field | Contents | +| ------------- | ---------------------------------------------------------------------------------------- | +| `run_id` | The run that asked | +| `answer_id` | The id of the fact | +| `answer` | The answer | +| `claimed_for` | Whom the caller says it is answering for, when given | +| `answered_by` | The caller who answered, by `id` and `name`; for a run answered by a reply, the playbook | +| `reply` | For a run answered by a reply, that reply's identity | +| `answered_at` | When the answer was recorded | The run takes the answer within about a second (the server checks waiting runs every second by default), as [Answering a request](interaction-format.md#answering-a-request) explains. @@ -437,7 +439,7 @@ A cancelled run's rejection names the kind of its cancellation: ## Runs and results -A run records its `run_id`, `type`, `name`, `definition_version`, `status`, timestamps and caller identity. A successful run includes `output`, and a rejected run includes a rejection. `get_run` also returns the detailed `record`. A run whose output takes an [output shape](output-shapes.md#what-a-run-shows) says it as `output_shape`, such as `"output_shape": "series"` on a question to a prediction function; a build of a prediction function, and a run of a definition that declares no shape, has none. +A run records its `run_id`, `type`, `name`, `definition_version`, `status`, timestamps and `started_by`, the caller who started it, by `id` and `name`. A successful run includes `output`, and a rejected run includes a rejection. `get_run` also returns the detailed `record`. A run whose output takes an [output shape](output-shapes.md#what-a-run-shows) says it as `output_shape`, such as `"output_shape": "series"` on a question to a prediction function; a build of a prediction function, and a run of a definition that declares no shape, has none. Most runs finish within the request that starts them. These runs finish later, so `run_definition` returns them `started`, unless one ended before its first wait: @@ -1101,7 +1103,7 @@ A connection is a tool server that a person adds to the org through their agent, | `start` | `POST /connections/{id}/consent` | any, with the link's token and the adder's session | `token` | | `finish` | `POST /connections/{id}/consent/code` | any, with the link's token and the adder's session | `token`, `code`, `state`, and `iss` where the authorization server sent one; or `token`, `state` and `handle` for a consent begun through Auto's consent service | -Every operation answers the connection: `id`, a UUIDv7; `name`; `type`, `mcp` or `openapi`; `provider`, the name of the provider it was added from, or `null` for a server given by hand; `placeholders`, the values given for the provider's placeholders, or `null`; `entry`; `credential`, how it is credentialled, never its value, and for a connection connected by consent the client it was made with; `status`, `pending`, `connected`, `broken` or `removed`; `added_by`; `added_at`; `connected_at`; `connected_by`, the id of the caller who connected it, by approving it or giving its credential, or `null`; `granted_scope`, for a connection connected by consent, the scope the server granted, which may be fewer scopes than its credential asked, as when the person unticks one on the provider's page, or `null`; `credential_ends_with`, the last four characters of a secret or client secret of twelve characters or more, which the secret store keeps beside the value, or `null`; and `broken`, why it is broken, or `null`. `add_connection` answers `201` with the connection and `connect`, `{ url, expires_at }`: `url` is Studio's page `connect` under the Studio the person can reach, `/studio/connect?connection=&token=` where the runtime serves Studio, as it does in local mode, since that page knows the runtime that serves it and asks it for the org, and `https://studio.on.auto/connect?server=&org=&connection=&token=` otherwise; the runtime's origin is `PUBLIC_URL`, or the server's own address in local mode, and the link expires ten minutes on. `list_connections` answers them newest first in `connections`, the removed ones only with `include_removed`. `read_link` answers the connection with `link`, `{ expires_at }`, when its link expires, so the page can say how long the person has, `consent`, `{ started_at, expires_at }`, while a consent is started and not finished, and, for a connection whose credential is `authorization`, `client_source`, the source of the client a start would take now, by the rule the start takes it by, `auto` where it would begin through Auto's consent service, left out where the runtime has no client the provider's way takes; `approve`, `give_credential` and `finish`, which use the link up, answer the connection alone. +Every operation answers the connection: `id`, a UUIDv7; `name`; `type`, `mcp` or `openapi`; `provider`, the name of the provider it was added from, or `null` for a server given by hand; `placeholders`, the values given for the provider's placeholders, or `null`; `entry`; `credential`, how it is credentialled, never its value, and for a connection connected by consent the client it was made with; `status`, `pending`, `connected`, `broken` or `removed`; `added_by`, the caller who added it, by `id` and `name`; `added_at`; `connected_at`; `connected_by`, the caller who connected it, by approving it or giving its credential, by `id` and `name`, or `null`; `granted_scope`, for a connection connected by consent, the scope the server granted, which may be fewer scopes than its credential asked, as when the person unticks one on the provider's page, or `null`; `credential_ends_with`, the last four characters of a secret or client secret of twelve characters or more, which the secret store keeps beside the value, or `null`; and `broken`, why it is broken, or `null`. `add_connection` answers `201` with the connection and `connect`, `{ url, expires_at }`: `url` is Studio's page `connect` under the Studio the person can reach, `/studio/connect?connection=&token=` where the runtime serves Studio, as it does in local mode, since that page knows the runtime that serves it and asks it for the org, and `https://studio.on.auto/connect?server=&org=&connection=&token=` otherwise; the runtime's origin is `PUBLIC_URL`, or the server's own address in local mode, and the link expires ten minutes on. `list_connections` answers them newest first in `connections`, the removed ones only with `include_removed`. `read_link` answers the connection with `link`, `{ expires_at }`, when its link expires, so the page can say how long the person has, `consent`, `{ started_at, expires_at }`, while a consent is started and not finished, and, for a connection whose credential is `authorization`, `client_source`, the source of the client a start would take now, by the rule the start takes it by, `auto` where it would begin through Auto's consent service, left out where the runtime has no client the provider's way takes; `approve`, `give_credential` and `finish`, which use the link up, answer the connection alone. - With `provider`, the provider gives the server and how it is credentialled. `name` is the provider's when left out; `placeholders` gives the value of each placeholder the provider takes; `credential` may give its `kind` alone, one of the provider's ways, and when it is left out the addition takes the first way `list_providers` marks offered, or the provider's first where it offers none, which the addition then refuses in its words; and `entry` may give `playbooks`, `allowed` and `record_content` alone. `type`, the server's fields of `entry` and every field of `credential` but `kind` are refused, each at its field, since the provider gives them; `credential.client_id` is refused since the person gives the client id on the page. The connection's `entry` and `credential` are the provider's with each value in its place, and every rule below applies to them as to a server given by hand. Without `provider`, `name`, `type`, `entry` and `credential` are each required and `placeholders` is refused. - `entry` holds the server without its credential: `url` for `mcp`; `document` and `base_url` for `openapi`; and `headers`, `query_params`, `allowed`, `playbooks`, `record_content` and `request_id`, as an entry of the configuration file has them. `url` and `document` are each an `https` URL, or an `http` URL on a loopback address. diff --git a/docs/reference/interaction-format.md b/docs/reference/interaction-format.md index 6d3349ee0..77c2af311 100644 --- a/docs/reference/interaction-format.md +++ b/docs/reference/interaction-format.md @@ -365,7 +365,7 @@ Rules of an answer: - A caller that may write to the playbook can answer. - The answer is checked against the answer schema the request recorded, and takes at most 64 KiB as JSON. - It is recorded on the run as one fact, `request_answered`, with the answer and `claimed_for`. `claimed_for` is whom the caller says it answers for: a claim the runtime keeps and never checks. The fact records the caller and the time itself. -- `answer_interaction` returns that fact: the `run_id`, the `answer_id` of the fact (which `get_event` reads), the `answer`, `claimed_for`, `answered_by` (the caller's id) and `answered_at`. +- `answer_interaction` returns that fact: the `run_id`, the `answer_id` of the fact (which `get_event` reads), the `answer`, `claimed_for`, `answered_by` (the caller who answered, by `id` and `name`) and `answered_at`. - The request leaves the inbox at once. The run takes the answer within about a second (the server checks waiting runs every second by default). It succeeds with the answer as its output, settled as the caller that answered. - An answer that does not match is `invalid_input`, with a pointer under `/answer` for each problem, and leaves the request open. - The same answer sent again returns the fact the run took. A different answer is `conflict`, since a run takes one answer. diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index c43c3bc4e..84e0da93a 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -30,9 +30,10 @@ A reasoning function can call tools itself, through tool servers the runtime's o ### What a connecting agent is told -An agent that connects receives instructions written for the tools its key or token may call. They cover: +An agent that connects receives instructions written for its caller and the tools its key or token may call. They cover: -- first, that whatever the person wants done that should keep working after the conversation, however they put it and in any line of business, is a playbook on Auto Runtime, not a document, a plan, a note or a one-off answer; +- first, whom the connection acts as, by name and id, and in which org: `This connection acts as Ada Lovelace (person:user_2ada) in the org acme.` for a signed-in person, the local developer by the name of the account the server runs as and the id `local`, and `This connection acts as the key build-bot in the org acme.` for a key. So an agent that reads who created, added, connected, started, answered, trained, approved or refused something tells the person it acts for from the others; +- then, that whatever the person wants done that keeps working after the conversation, however they put it and in any line of business, is a playbook on Auto Runtime, not a document, a plan, a note or a one-off answer; - what a playbook is, in the words of the [terminology](../concepts/terminology.md), with a sentence for each function type the runtime runs and one for workflows; - that what the person wants kept, for anyone or a workflow to ask later, goes in a recall function of the playbook and not in the agent's own notes; - what the connection acts on: the caller's own org on `/mcp`, one org's playbooks on `/orgs/{org}/mcp`, or one playbook on `/orgs/{org}/playbooks/{playbook}/mcp`; diff --git a/docs/reference/workflow-format.md b/docs/reference/workflow-format.md index e6a4d6efd..488185cfa 100644 --- a/docs/reference/workflow-format.md +++ b/docs/reference/workflow-format.md @@ -231,7 +231,7 @@ Each filter is held to its own bounds, and its failure affects no other workflow - A new version's triggers are compared with those of the version before, by kind and place. A trigger it leaves unchanged goes on as it was, with its times and its running run. From then on it starts the new version. A trigger it changes or adds applies from the new version's saving; a changed schedule counts its times from then. A trigger it removes stops. - Saving a version without a schedule, or retiring the workflow, stops every trigger. - A run that a trigger starts uses the version that was current when the event was recorded or the time came. Its `run_id` is derived from the workflow, that version, the trigger and the event or due time. So an event or a time starts it once. -- The playbook itself starts a triggered run, which acts for the playbook. Its `started_by` is `playbook:` and the playbook's id. Each step may read and write that playbook and nothing else. The run never acts for a person, so no key's permissions or revocation affect it. +- The playbook itself starts a triggered run, which acts for the playbook. Its `started_by` has the `id` `playbook:` and the playbook's id, and the playbook's id as its `name`. Each step may read and write that playbook and nothing else. The run never acts for a person, so no key's permissions or revocation affect it. ### Trigger limits @@ -300,7 +300,7 @@ A `switch` tests its cases in order and follows the `then` of the first case who The task's output is that run's output: a reasoning function's text or JSON, an interaction function's answer from its tool or its request, a prediction function's series or its build's outcome, what a recall function returned from its view, the value a computation function's program gave, or the output of the workflow it ran. -Each time the task runs, including on a retry, it starts a separate run of the function, recorded under its own `run_id`. That run acts for the caller who started the workflow, with the permissions that caller had when the workflow started. +Each time the task runs, including on a retry, it starts a separate run of the function, recorded under its own `run_id`. That run acts for the caller who started the workflow, with the permissions that caller had when the workflow started, and names that caller, by id and name, as its `started_by`. Some runs finish within their call, and others later. A task waits for the longest its definition's run may take, plus one minute: diff --git a/packages/api/README.md b/packages/api/README.md index e837f23f6..ebab5c667 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -148,9 +148,9 @@ So invalid arguments are a tool result with `isError` and an `invalid_input` pro **Server identity.** `serverInfo` carries the name and version the server passes in, the root package `@on.auto/runtime` and its release version from the manifest, and the title `Auto Runtime`, which a client shows in place of the name. -**Instructions.** Every endpoint answers `initialize` with instructions built by `instructionsFor` in `src/mcp/instructions.ts`. They open with the claim that whatever the person wants done that should keep working after the conversation, however they put it and in any line of business, is a playbook on this server, not a document, a plan, a note or a one-off answer, and establish that Auto Runtime makes and runs playbooks, Studio manages them, and the agent is a separate client. Each playbook contains one brain of reusable functions and its workflows; functions run directly or in workflows, and runs belong to the playbook. +**Instructions.** Every endpoint answers `initialize` with instructions built for the caller of the session by `instructionsActingAs` in `src/mcp/instructions.ts`. They open with whom the connection acts as, by name and id, and in which org, `This connection acts as Ada Lovelace (person:user_2ada) in the org acme.` for a signed-in person and the local developer, and `This connection acts as the key build-bot in the org acme.` for a key, whose name is its id, so an agent that reads what a record says of who did something tells the caller it serves from the others. The org is the one the session acts in, the caller's own on `/mcp` and the one the path names on the other endpoints. What follows is the same for every caller of the endpoint, `instructionsFor`: it begins with the claim that whatever the person wants done that keeps working after the conversation, however they put it and in any line of business, is a playbook here, not a document, a plan, a note or a one-off answer, and establishes that Auto Runtime makes and runs playbooks, Studio manages them, and the agent is a separate client. Each playbook contains one brain of reusable functions and its workflows; functions run directly or in workflows, and runs belong to the playbook. -The instructions are composed from the tools the caller may use and the registered definition types. They explain connection scope, put what the person wants kept in a recall function rather than the agent's own notes where the server runs recall functions, require reading the format before writing a definition and ask for its overview, name `list_models` where it is listed and `search` is not, and direct an answer to an outstanding interaction back to the same run. Where `search` and `add_connection` are both listed, they say that a service a playbook will use is connected on this server, whatever connector the agent has of its own: asked to connect, link, sign in to or use one, the agent calls `search` with its words, then `add_connection` with the provider's name, which the person approves on the page it links, and says a service cannot be connected only when no provider or server matches. Scoped connections and read-only callers receive only applicable instructions. `src/mcp/instructions.test.ts` checks these combinations and the 2,000-character limit, about what Claude Code shows of a server's instructions; startup refuses instructions that exceed the bound. Tool descriptions and format guides carry the details that do not fit in initialization: `get_guide`'s description names the recipes the connection's tools can follow, `run_definition`'s says that a run that finishes later answers started and `get_run` shows how it ended, `list_models`'s and `search`'s say how a reasoning function names its model and its tools, as the reasoning function's guide does, the prediction function's guide says that a build answers started, `execute`'s and `dry_run`'s say what each does, and those of `list_runs`, `list_playbook_events` and `list_interactions` say that their pages are read on only as the person needs. +The instructions are composed from the tools the caller may use and the registered definition types. They explain connection scope, put what the person wants kept in a recall function rather than the agent's own notes where the server runs recall functions, require reading the format before writing a definition and ask for its overview, name `list_models` where it is listed and `search` is not, and direct an answer to an outstanding interaction back to the same run. Where `search` and `add_connection` are both listed, they say that a service a playbook will use is connected on this server, whatever connector the agent has of its own: asked to connect, link, sign in to or use one, the agent calls `search` with its words, then `add_connection` with the provider's name, which the person approves on the page it links, and says a service cannot be connected only when no provider or server matches. Scoped connections and read-only callers receive only applicable instructions. `src/mcp/instructions.test.ts` checks these combinations, the opening line of a signed-in person, a key and the local developer, and the 2,000-character limit, about what Claude Code shows of a server's instructions, over the whole text, opening line included, for a person named in 20 characters whose user id takes 32 and whose org id 30, `callerTheInstructionsHold` of `src/bounds/served-bounds.ts`; startup refuses an endpoint whose instructions exceed the bound for that person. The opening line takes 130 of the characters for that person, and the rest is worded tightly enough to leave them: with every tool, 1,869 on `/mcp`, 1,866 on the org endpoint and 1,502 on the playbook endpoint, so 1,999, 1,996 and 1,632 in all (`src/mcp/tool-calling-instructions.test.ts`). A caller whose name, id or org is longer is served the longer text, which a client that shows only 2,000 characters cuts at its end. Tool descriptions and format guides carry the details that do not fit in initialization: `get_guide`'s description names the recipes the connection's tools can follow, `run_definition`'s says that a run that finishes later answers started and `get_run` shows how it ended, `list_models`'s and `search`'s say how a reasoning function names its model and its tools, as the reasoning function's guide does, the prediction function's guide says that a build answers started, `execute`'s and `dry_run`'s say what each does, and those of `list_runs`, `list_playbook_events` and `list_interactions` say that their pages are read on only as the person needs. **A link to open.** An operation whose definition gives `linkToOpen`, as `add_connection` of `@on.auto/connections` does with Studio's page that approves a connection, puts the page in its output, and its tool also asks the client to open it, with the message the definition gives, through the `url` form of elicitation, when the client declared that form in the capabilities its request carries (`src/tools/linked-answers.ts`). Only a request of the `2026-07-28` revision carries them: the handler serves an earlier revision statelessly, each request "by a fresh instance from the same factory", as the SDK documents its `legacy: 'stateless'`, so the capabilities a `2025-11-25` client declared in its `initialize` never reach the instance that answers its call, nor would the client's answer to the SDK's `elicitInput`, which takes the `url` form, reach the instance that asked; a client of that revision is answered at once, the page in the output. A tool that asks answers `input_required`, with one `url` elicitation and a `requestState` that `createRequestStateCodec` of the SDK signs with the current key of `retryStateKeys`, the HMAC keys `mcpRoutes` is given, and binds with the codec's `bind` to the caller and the org of the request: the tool's answer, with the tool, the caller and the org it was made for. The client opens the page, or the person declines, and retries the call with the state; the SDK verifies the state, and the tool answers what it kept for the same tool, caller and org without running the operation again, so a retry adds nothing a second time. The retry state is signed under a key the runtime keeps for itself in its secret store: 32 random bytes the first server of a ledger makes and names by a fact of the runtime's own, `own_key_kept` in `runtime/own-keys`, as the server's README says, which the server gives `mcpRoutes` as the `current` of `retryStateKeys`, with no `previous`; so every server that shares the ledger signs and verifies under the same key, whichever of them asked, across a restart and across a rotation of the secrets key, which wraps that key again and leaves it as it is, as the server's `src/connections/connections-over-mcp.test.ts` shows. A state is verified under `current` and then under `previous`, which is kept for a later rotation of that key, as `src/tools/linked-answers.test.ts` shows; a state of another key, made for another caller or org, or older than its ten minutes, is refused by the SDK as `-32602`, `Invalid or expired requestState`, and what the first call did stands. @@ -160,7 +160,7 @@ The instructions are composed from the tools the caller may use and the register | Bound | Value | When it is reached | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| Instructions of a connection | 2,000 characters | The server refuses to start | +| Instructions of a connection | 2,000 characters, the line that says whom it acts as included, for the person `callerTheInstructionsHold` names | The server refuses to start | | A tool description | 800 characters, 3 to 8 sentences | The server refuses to start | | An argument's description | 300 characters, at any depth of the input | The server refuses to start | | Tools on a connection | a limit of 32, `get_guide` included, which does not move when a tool is added; the server lists 28 on `/mcp`, 10 on the org endpoint and 20 on the playbook endpoint | The server refuses to start | diff --git a/packages/api/src/bounds/served-bounds.ts b/packages/api/src/bounds/served-bounds.ts index e868277b2..45fdea30b 100644 --- a/packages/api/src/bounds/served-bounds.ts +++ b/packages/api/src/bounds/served-bounds.ts @@ -1,9 +1,16 @@ import { Buffer } from 'node:buffer'; -import type { McpEndpoint } from '../mcp/instructions.ts'; +import type { ActingAs, McpEndpoint } from '../mcp/instructions.ts'; export const mostInstructionCharacters = 2000; +const heldOrg = 'o'.repeat(30); + +export const callerTheInstructionsHold: ActingAs = { + caller: { id: `person:${'u'.repeat(32)}`, name: 'n'.repeat(20), org: heldOrg, permissions: [], playbooks: '*' }, + org: heldOrg, +}; + export const mostDescriptionCharacters = 800; const fewestDescriptionSentences = 3; diff --git a/packages/api/src/hand-off/caller-hand-off.test.ts b/packages/api/src/hand-off/caller-hand-off.test.ts index 3ad7c96b0..847bb544e 100644 --- a/packages/api/src/hand-off/caller-hand-off.test.ts +++ b/packages/api/src/hand-off/caller-hand-off.test.ts @@ -4,7 +4,13 @@ import { describe, expect, it } from 'vitest'; import { authInfoFor, playbookCallOf, orgCallOf } from './caller-hand-off.ts'; -const caller: CallerIdentity = { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: ['alpha'] }; +const caller: CallerIdentity = { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: allPermissions, + playbooks: ['alpha'], +}; class Invocation extends Context.Service()('test/Invocation') {} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 36ae18147..907d5fd5d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -27,7 +27,9 @@ export { guideRoutes } from './guides/guide-routes.ts'; export { apiCatalogRoutes } from './discovery/api-catalog.ts'; export { clientMetadataRoutes, type ClientMetadataDocument } from './discovery/client-metadata.ts'; export { + instructionsActingAs, instructionsFor, + type ActingAs, type DefinitionType, type McpEndpoint, type RecipeCalls, diff --git a/packages/api/src/mcp/instructions.test.ts b/packages/api/src/mcp/instructions.test.ts index 421f84435..8fc7583d9 100644 --- a/packages/api/src/mcp/instructions.test.ts +++ b/packages/api/src/mcp/instructions.test.ts @@ -3,8 +3,12 @@ import { readFileSync } from 'node:fs'; import { internalTermsIn } from '@on.auto/operations/testing'; import { describe, expect, it } from 'vitest'; +import { callerTheInstructionsHold } from '../bounds/served-bounds.ts'; import { + buildBot, + localDeveloper, playbookEndpoint, + signedInAda, definitionTypes, everyEndpoint, orgEndpoint, @@ -12,32 +16,32 @@ import { queriesInsideAPlaybook, withConnections, } from '../testing/served-instructions.ts'; -import { instructionsFor, type ServedTools } from './instructions.ts'; +import { instructionsActingAs, instructionsFor, type ServedTools } from './instructions.ts'; const servedOnMcp = - "Whatever the person wants done that should keep working after this conversation, however they put it and in any line of business, is a playbook on this server, not a document, a plan, a note or a one-off answer. Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run directly or in workflows; runs belong to the playbook. A reasoning function has a prompt and calls a language model. An interaction function asks a system and answers at once, or a person and answers started. A computation function runs a program whose output is the same for the same input. Recall retrieves run results, definitions and events, never run inputs or tool calls. What the person wants kept goes in a recall function, for anyone or a workflow to ask later, not your notes. A prediction function answers from a model trained on the playbook's history. A workflow runs functions in steps, waits for input and can start on a schedule or an event. This connection acts in the caller's own org: list_playbooks shows its playbooks and create_playbook makes one. Read a definition's format with get_guide; give it an overview. A service a playbook will use is connected on this server, whatever connector the agent has of its own: asked to connect, link, sign in to or use one, call search with its words, then add_connection with the provider's name, which the person approves on the page it links. Say a service cannot be connected only when no provider or server matches. When the person approves, rejects or otherwise answers what a run waits on, answer its request with answer_interaction, not a new run. Say what was done and what the person can do next, in the words of playbooks, functions, workflows and runs, not the tools' names, fields or rules; an id or status only where needed to act. A tool that cannot do what was asked says why and what to change."; + "Whatever the person wants done that keeps working after this conversation, however they put it and in any line of business, is a playbook here, not a document, plan, note or one-off answer. Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run alone or in workflows; runs belong to the playbook. A reasoning function has a prompt and calls a language model. An interaction function asks a system and answers at once, or a person and answers started. A computation function runs a program: same input, same output. Recall retrieves run results, definitions and events, never run inputs or tool calls. What the person wants kept goes in a recall function for anyone or a workflow to ask later, not your notes. A prediction function answers from a model trained on the playbook's history. A workflow runs functions in steps, waits for input and can start on a schedule or event. The org is the caller's own: list_playbooks shows its playbooks and create_playbook makes one. Read a definition's format with get_guide; give it an overview. A service a playbook will use is connected here, whatever connector you have: asked to connect, link, sign in to or use one, call search with its words, then add_connection with the provider's name; the person approves on the page it links. Say a service cannot be connected only if no provider or server matches. When the person approves, rejects or otherwise answers what a run waits on, use answer_interaction, not a new run. Say what was done and what the person can do next in the words of playbooks, functions, workflows and runs, not tools' names, fields or rules; an id or status only where needed to act. A tool that cannot do what was asked says why and what to change."; const servedOnAnOrg = - "Whatever the person wants done that should keep working after this conversation, however they put it and in any line of business, is a playbook on this server, not a document, a plan, a note or a one-off answer. Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run directly or in workflows; runs belong to the playbook. A reasoning function has a prompt and calls a language model. An interaction function asks a system and answers at once, or a person and answers started. A computation function runs a program whose output is the same for the same input. Recall retrieves run results, definitions and events, never run inputs or tool calls. What the person wants kept goes in a recall function, for anyone or a workflow to ask later, not your notes. A prediction function answers from a model trained on the playbook's history. A workflow runs functions in steps, waits for input and can start on a schedule or an event. This connection manages the playbooks of one org: list_playbooks shows them and create_playbook makes one, and a playbook's functions and workflows are made on the playbook's own connection, /orgs/{org}/playbooks/{playbook}/mcp. get_guide holds what these words mean and how each kind of definition is written. A service a playbook will use is connected on this server, whatever connector the agent has of its own: asked to connect, link, sign in to or use one, call search with its words, then add_connection with the provider's name, which the person approves on the page it links. Say a service cannot be connected only when no provider or server matches. Say what was done and what the person can do next, in the words of playbooks, functions, workflows and runs, not the tools' names, fields or rules; an id or status only where needed to act. A tool that cannot do what was asked says why and what to change."; + "Whatever the person wants done that keeps working after this conversation, however they put it and in any line of business, is a playbook here, not a document, plan, note or one-off answer. Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run alone or in workflows; runs belong to the playbook. A reasoning function has a prompt and calls a language model. An interaction function asks a system and answers at once, or a person and answers started. A computation function runs a program: same input, same output. Recall retrieves run results, definitions and events, never run inputs or tool calls. What the person wants kept goes in a recall function for anyone or a workflow to ask later, not your notes. A prediction function answers from a model trained on the playbook's history. A workflow runs functions in steps, waits for input and can start on a schedule or event. This connection manages the org's playbooks: list_playbooks shows them and create_playbook makes one; a playbook's functions and workflows are made on its connection, /orgs/{org}/playbooks/{playbook}/mcp. get_guide explains these words and the format of each definition. A service a playbook will use is connected here, whatever connector you have: asked to connect, link, sign in to or use one, call search with its words, then add_connection with the provider's name; the person approves on the page it links. Say a service cannot be connected only if no provider or server matches. Say what was done and what the person can do next in the words of playbooks, functions, workflows and runs, not tools' names, fields or rules; an id or status only where needed to act. A tool that cannot do what was asked says why and what to change."; const servedOnAPlaybook = - "Whatever the person wants done that should keep working after this conversation, however they put it and in any line of business, is a playbook on this server, not a document, a plan, a note or a one-off answer. Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run directly or in workflows; runs belong to the playbook. A reasoning function has a prompt and calls a language model. An interaction function asks a system and answers at once, or a person and answers started. A computation function runs a program whose output is the same for the same input. Recall retrieves run results, definitions and events, never run inputs or tool calls. What the person wants kept goes in a recall function, for anyone or a workflow to ask later, not your notes. A prediction function answers from a model trained on the playbook's history. A workflow runs functions in steps, waits for input and can start on a schedule or an event. This connection acts inside one playbook. Read a definition's format with get_guide; give it an overview. When the person approves, rejects or otherwise answers what a run waits on, answer its request with answer_interaction, not a new run. Say what was done and what the person can do next, in the words of playbooks, functions, workflows and runs, not the tools' names, fields or rules; an id or status only where needed to act. A tool that cannot do what was asked says why and what to change."; + "Whatever the person wants done that keeps working after this conversation, however they put it and in any line of business, is a playbook here, not a document, plan, note or one-off answer. Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run alone or in workflows; runs belong to the playbook. A reasoning function has a prompt and calls a language model. An interaction function asks a system and answers at once, or a person and answers started. A computation function runs a program: same input, same output. Recall retrieves run results, definitions and events, never run inputs or tool calls. What the person wants kept goes in a recall function for anyone or a workflow to ask later, not your notes. A prediction function answers from a model trained on the playbook's history. A workflow runs functions in steps, waits for input and can start on a schedule or event. This connection acts inside one playbook. Read a definition's format with get_guide; give it an overview. When the person approves, rejects or otherwise answers what a run waits on, use answer_interaction, not a new run. Say what was done and what the person can do next in the words of playbooks, functions, workflows and runs, not tools' names, fields or rules; an id or status only where needed to act. A tool that cannot do what was asked says why and what to change."; const interactionSentence = 'An interaction function asks a system and answers at once, or a person and answers started.'; const answeringSentence = - 'When the person approves, rejects or otherwise answers what a run waits on, answer its request with answer_interaction, not a new run.'; + 'When the person approves, rejects or otherwise answers what a run waits on, use answer_interaction, not a new run.'; const recallSentence = 'Recall retrieves run results, definitions and events, never run inputs or tool calls.'; const whatThePersonWantsKept = - 'What the person wants kept goes in a recall function, for anyone or a workflow to ask later, not your notes.'; + 'What the person wants kept goes in a recall function for anyone or a workflow to ask later, not your notes.'; const predictionSentence = "A prediction function answers from a model trained on the playbook's history."; const claim = - 'Whatever the person wants done that should keep working after this conversation, however they put it and in any line of business, is a playbook on this server, not a document, a plan, a note or a one-off answer.'; + 'Whatever the person wants done that keeps working after this conversation, however they put it and in any line of business, is a playbook here, not a document, plan, note or one-off answer.'; function asServedWithFourTypes(servedText: string): string { return [interactionSentence, predictionSentence, answeringSentence].reduce( @@ -63,6 +67,33 @@ const resourcesOnTheTerminologyPage: ReadonlySet = new Set( ), ); +const openings = [ + [ + 'a signed-in person', + signedInAda, + 'acme', + 'This connection acts as Ada Lovelace (person:user_2ada) in the org acme.', + ], + ['a key', buildBot, 'acme', 'This connection acts as the key build-bot in the org acme.'], + [ + 'the local developer, in the org it acts in', + localDeveloper, + 'globex', + 'This connection acts as ada (local) in the org globex.', + ], +] as const; + +describe('the instructions a connection is served', () => { + it.each(openings)( + 'open, for %s, with whom it acts as, by name and id, and in which org', + (_case, caller, org, opening) => { + expect(instructionsActingAs({ caller, org }, 'own org', withConnections(ownOrg), definitionTypes)).toBe( + `${opening} ${servedOnMcp}`, + ); + }, + ); +}); + describe('the instructions of each endpoint, for a key that may call every tool', () => { it.each([ ['own org', withConnections(ownOrg), servedOnMcp], @@ -88,9 +119,15 @@ describe('the instructions of each endpoint, for a key that may call every tool' }, ); - it.each(everyEndpoint)('stay under 2,000 characters on the %s endpoint', (endpoint, served) => { - expect(instructionsFor(endpoint, served, definitionTypes).length).toBeLessThan(2000); - }); + it.each(everyEndpoint)( + 'stay within 2,000 characters on the %s endpoint, opening line included, for a person named in 20 characters, of a user id of 32 and an org id of 30', + (endpoint, served) => { + const opened = instructionsActingAs(callerTheInstructionsHold, endpoint, served, definitionTypes); + + expect(opened.length).toBeLessThanOrEqual(2000); + expect(opened).toMatch(/^This connection acts as n{20} \(person:u{32}\) in the org o{30}\. /u); + }, + ); }); describe('what the instructions say of what the person wants kept', () => { @@ -135,8 +172,8 @@ describe('the instructions of a key that may only read', () => { 'add_connection', ].filter((name) => reading.includes(name)), ).toEqual([]); - expect(reading).toContain("This connection acts in the caller's own org: list_playbooks shows its playbooks."); - expect(reading).toContain('get_guide holds what these words mean and how each kind of definition is written.'); + expect(reading).toContain("The org is the caller's own: list_playbooks shows its playbooks."); + expect(reading).toContain('get_guide explains these words and the format of each definition.'); }); }); @@ -148,22 +185,20 @@ describe('instructions scoped to a playbook endpoint', () => { definitionTypes, ); - expect(reading).toContain( - 'get_guide holds what these words mean and how each kind of definition is written. Say what was done', - ); + expect(reading).toContain('get_guide explains these words and the format of each definition. Say what was done'); }); it('on an org endpoint that lists the models and no tool servers, say which models a reasoning function names', () => { const served = { orgTools: ['list_playbooks', 'list_models', 'get_guide'], playbookTools: [] }; expect(instructionsFor('org', served, definitionTypes)).toContain( - 'list_models lists the models this server can call, which a reasoning function names. get_guide holds', + 'list_models lists the models this server can call, which a reasoning function names. get_guide explains', ); }); it('on an org endpoint that only creates playbooks, say that it makes a playbook', () => { expect(instructionsFor('org', { orgTools: ['create_playbook'], playbookTools: [] }, definitionTypes)).toContain( - "This connection manages the playbooks of one org: create_playbook makes a playbook, and a playbook's functions and workflows are made on the playbook's own connection, /orgs/{org}/playbooks/{playbook}/mcp.", + "This connection manages the org's playbooks: create_playbook makes a playbook; a playbook's functions and workflows are made on its connection, /orgs/{org}/playbooks/{playbook}/mcp.", ); }); }); @@ -173,7 +208,7 @@ describe('what the instructions say of a prediction function', () => { const predicting = definitionTypes.filter(({ type }) => type === 'prediction'); expect(instructionsFor('playbook', playbookEndpoint, predicting)).toContain( - `Functions run directly or in workflows; runs belong to the playbook. ${predictionSentence} This connection acts inside one playbook.`, + `Functions run alone or in workflows; runs belong to the playbook. ${predictionSentence} This connection acts inside one playbook.`, ); expect( instructionsFor('playbook', playbookEndpoint, definitionTypes.slice(0, 1)).includes(predictionSentence), @@ -197,7 +232,7 @@ describe('what the instructions say of the definition types', () => { const drafting = [{ type: 'drafting', noun: 'draft', guide: 'drafting' }]; expect(instructionsFor('playbook', playbookEndpoint, drafting)).toContain( - `${claim} Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run directly or in workflows; runs belong to the playbook. This connection acts inside one playbook. Read a definition's format`, + `${claim} Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run alone or in workflows; runs belong to the playbook. This connection acts inside one playbook. Read a definition's format`, ); }); @@ -214,26 +249,6 @@ describe('what the instructions say of the definition types', () => { }); }); -describe('what the instructions say of the guides', () => { - it("point to get_guide for a definition's format and ask for its overview on every endpoint that writes one, and name no recipe, which get_guide names", () => { - const writing = everyEndpoint.filter(([endpoint]) => endpoint !== 'org'); - - expect( - writing.filter( - ([endpoint, served]) => - !instructionsFor(endpoint, served, definitionTypes).includes( - "Read a definition's format with get_guide; give it an overview.", - ), - ), - ).toEqual([]); - expect( - everyEndpoint.filter(([endpoint, served]) => - instructionsFor(endpoint, served, definitionTypes).includes('recipe'), - ), - ).toEqual([]); - }); -}); - describe('what the instructions say of a run that waits', () => { it('say how to answer what a run waits on only where answer_interaction is listed', () => { const answering = instructionsFor('playbook', playbookEndpoint, definitionTypes); diff --git a/packages/api/src/mcp/instructions.ts b/packages/api/src/mcp/instructions.ts index 3fdccd7ac..1dcb747ac 100644 --- a/packages/api/src/mcp/instructions.ts +++ b/packages/api/src/mcp/instructions.ts @@ -1,3 +1,6 @@ +import { isKeyHolder } from '@on.auto/identity'; +import type { CallerIdentity } from '@on.auto/operations'; + export type McpEndpoint = 'org' | 'playbook' | 'own org'; export interface DefinitionType { @@ -16,6 +19,11 @@ export interface RecipeCalls { readonly calls: readonly string[]; } +export interface ActingAs { + readonly caller: CallerIdentity; + readonly org: string; +} + interface Serving { readonly endpoint: McpEndpoint; readonly listed: (name: string) => boolean; @@ -30,23 +38,23 @@ const reasoningFunctionType = 'reasoning'; const purposesByType: Readonly> = { reasoning: ['A reasoning function has a prompt and calls a language model.'], interaction: ['An interaction function asks a system and answers at once, or a person and answers started.'], - computation: ['A computation function runs a program whose output is the same for the same input.'], + computation: ['A computation function runs a program: same input, same output.'], recall: [ 'Recall retrieves run results, definitions and events, never run inputs or tool calls.', - 'What the person wants kept goes in a recall function, for anyone or a workflow to ask later, not your notes.', + 'What the person wants kept goes in a recall function for anyone or a workflow to ask later, not your notes.', ], prediction: ["A prediction function answers from a model trained on the playbook's history."], - workflow: ['A workflow runs functions in steps, waits for input and can start on a schedule or an event.'], + workflow: ['A workflow runs functions in steps, waits for input and can start on a schedule or event.'], }; export const whatThisServerIsFor = [ - 'Whatever the person wants done that should keep working after this conversation, however they put it and in any line of business, is a playbook on this server, not a document, a plan, a note or a one-off answer.', + 'Whatever the person wants done that keeps working after this conversation, however they put it and in any line of business, is a playbook here, not a document, plan, note or one-off answer.', 'Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client.', - 'A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run directly or in workflows; runs belong to the playbook.', + 'A playbook contains one brain (its reusable functions) and workflows. The brain shares its lifecycle. Functions run alone or in workflows; runs belong to the playbook.', ]; const howToAnswer = - "Say what was done and what the person can do next, in the words of playbooks, functions, workflows and runs, not the tools' names, fields or rules; an id or status only where needed to act."; + "Say what was done and what the person can do next in the words of playbooks, functions, workflows and runs, not tools' names, fields or rules; an id or status only where needed to act."; const whenAToolCannot = 'A tool that cannot do what was asked says why and what to change.'; @@ -58,16 +66,15 @@ function playbooksClause({ listed }: Serving, shows: string): readonly string[] function joinedClauses(opening: string, playbooks: readonly string[], rest: readonly string[]): string { const playbookWords = playbooks.length === 0 ? '' : `: ${playbooks.join(' and ')}`; - const restWords = rest.map((clause) => `, and ${clause}`).join(''); + const restWords = rest.map((clause) => `; ${clause}`).join(''); return `${opening}${playbookWords}${restWords}.`; } const whatTheConnectionDoes: Readonly string>> = { - 'own org': (serving) => - joinedClauses("This connection acts in the caller's own org", playbooksClause(serving, 'its playbooks'), []), + 'own org': (serving) => joinedClauses("The org is the caller's own", playbooksClause(serving, 'its playbooks'), []), org: (serving) => - joinedClauses('This connection manages the playbooks of one org', playbooksClause(serving, 'them'), [ - "a playbook's functions and workflows are made on the playbook's own connection, /orgs/{org}/playbooks/{playbook}/mcp", + joinedClauses("This connection manages the org's playbooks", playbooksClause(serving, 'them'), [ + "a playbook's functions and workflows are made on its connection, /orgs/{org}/playbooks/{playbook}/mcp", ]), playbook: () => 'This connection acts inside one playbook.', }; @@ -84,12 +91,12 @@ const modelsBeforeWriting: Sentences = ({ reasoning, listed }) => const guides: Sentences = ({ listed }) => [ listed('create_definition') ? "Read a definition's format with get_guide; give it an overview." - : 'get_guide holds what these words mean and how each kind of definition is written.', + : 'get_guide explains these words and the format of each definition.', ]; const connectingAService = [ - "A service a playbook will use is connected on this server, whatever connector the agent has of its own: asked to connect, link, sign in to or use one, call search with its words, then add_connection with the provider's name, which the person approves on the page it links.", - 'Say a service cannot be connected only when no provider or server matches.', + "A service a playbook will use is connected here, whatever connector you have: asked to connect, link, sign in to or use one, call search with its words, then add_connection with the provider's name; the person approves on the page it links.", + 'Say a service cannot be connected only if no provider or server matches.', ]; const connections: Sentences = ({ listed }) => (listed('search') && listed('add_connection') ? connectingAService : []); @@ -97,7 +104,7 @@ const connections: Sentences = ({ listed }) => (listed('search') && listed('add_ const answeringWhatRunsWaitOn: Sentences = ({ listed }) => listed('answer_interaction') ? [ - 'When the person approves, rejects or otherwise answers what a run waits on, answer its request with answer_interaction, not a new run.', + 'When the person approves, rejects or otherwise answers what a run waits on, use answer_interaction, not a new run.', ] : []; @@ -125,6 +132,14 @@ export function recipesFollowedWith( return recipes.filter(({ calls }) => calls.every((name) => names.includes(name))); } +function callerInWords(caller: CallerIdentity): string { + return isKeyHolder(caller) ? `the key ${caller.id}` : `${caller.name} (${caller.id})`; +} + +function whomTheConnectionActsAs({ caller, org }: ActingAs): string { + return `This connection acts as ${callerInWords(caller)} in the org ${org}.`; +} + export function instructionsFor( endpoint: McpEndpoint, tools: ServedTools, @@ -139,3 +154,12 @@ export function instructionsFor( }; return [...whatThisServerIsFor, ...orientation.flatMap((sentences) => sentences(serving))].join(' '); } + +export function instructionsActingAs( + actingAs: ActingAs, + endpoint: McpEndpoint, + tools: ServedTools, + definitionTypes: readonly DefinitionType[], +): string { + return `${whomTheConnectionActsAs(actingAs)} ${instructionsFor(endpoint, tools, definitionTypes)}`; +} diff --git a/packages/api/src/mcp/mcp-clients.test.ts b/packages/api/src/mcp/mcp-clients.test.ts index e44110f9f..2e5ad4b3f 100644 --- a/packages/api/src/mcp/mcp-clients.test.ts +++ b/packages/api/src/mcp/mcp-clients.test.ts @@ -93,7 +93,7 @@ describe.each(mcpClientKinds)('the %s client connecting to a playbook endpoint', expect(connected).toEqual({ protocolVersion: expectedProtocolVersion[kind], serverVersion: { ...testServerInfo, title: 'Auto Runtime' }, - instructions: instructionsFor('playbook', { orgTools: [], playbookTools }, testDefinitionTypes), + instructions: `This connection acts as the key acme-admin in the org acme. ${instructionsFor('playbook', { orgTools: [], playbookTools }, testDefinitionTypes)}`, }); }); diff --git a/packages/api/src/mcp/mcp-connection.ts b/packages/api/src/mcp/mcp-connection.ts index 49eb64165..72bb2db99 100644 --- a/packages/api/src/mcp/mcp-connection.ts +++ b/packages/api/src/mcp/mcp-connection.ts @@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server'; import type { Catalog, Dispatcher } from '@on.auto/operations'; import { + callerTheInstructionsHold, mostInstructionCharacters, mostListingBytes, mostToolsOnAConnection, @@ -14,7 +15,13 @@ import { guideToolDefinitionOf, guideToolName } from '../guides/guide-tool.ts'; import type { RunCall } from '../operations/operation-handler.ts'; import type { LinkedAnswers } from '../tools/linked-answers.ts'; import { listingBytesOf, type ListedTool } from '../tools/tool-listing.ts'; -import { instructionsFor, recipesFollowedWith, type McpEndpoint, type ServedTools } from './instructions.ts'; +import { + instructionsActingAs, + recipesFollowedWith, + type ActingAs, + type McpEndpoint, + type ServedTools, +} from './instructions.ts'; export interface ServerInfo { readonly name: string; @@ -39,10 +46,6 @@ const noChangesAnnounced = { listChanged: false }; export const serverTitle = 'Auto Runtime'; -function instructionsOf({ endpoint, shelf }: Connection, served: ServedTools): string { - return instructionsFor(endpoint, served, shelf.definitionTypes); -} - export interface EveryTool { readonly served: ServedTools; readonly listed: readonly ListedTool[]; @@ -65,23 +68,29 @@ export function connectionOf(endpoint: McpEndpoint, serving: McpServing, { serve ); requireTextWithin( `The instructions of the ${endpoint} endpoint`, - instructionsOf(connection, served), + instructionsActingAs(callerTheInstructionsHold, endpoint, served, connection.shelf.definitionTypes), mostInstructionCharacters, ); return connection; } +export interface ConnectedSession { + readonly connection: Connection; + readonly actingAs: ActingAs; + readonly states: LinkedAnswers['states']; +} + export function serverOf( - connection: Connection, + { connection, actingAs, states }: ConnectedSession, served: ServedTools, - states: LinkedAnswers['states'], registerTools: (server: GuideRegistry) => void, ): McpServer { + const { endpoint, shelf } = connection; const server = new McpServer( { ...connection.serving.serverInfo, title: serverTitle }, { capabilities: { tools: noChangesAnnounced, resources: noChangesAnnounced, prompts: noChangesAnnounced }, - instructions: instructionsOf(connection, served), + instructions: instructionsActingAs(actingAs, endpoint, served, shelf.definitionTypes), requestState: states, }, ); diff --git a/packages/api/src/mcp/mcp-own-org.test.ts b/packages/api/src/mcp/mcp-own-org.test.ts index 91052b24f..496e9efa2 100644 --- a/packages/api/src/mcp/mcp-own-org.test.ts +++ b/packages/api/src/mcp/mcp-own-org.test.ts @@ -93,13 +93,13 @@ describe('the tools of /mcp', () => { expect(tools.filter(({ outputSchema }) => outputSchema !== undefined)).toEqual([]); }); - it("carry the instructions of the caller's own org", async () => { + it("carry the instructions of the caller's own org, opened with the key the connection acts as", async () => { const instructions = await asKey(acmeAdmin.key, (session) => Promise.resolve(session.instructions)); expect(instructions).toBe( - instructionsFor('own org', { orgTools, playbookTools }, [{ type: 'notes', noun: 'note', guide: 'notebook' }]), + `This connection acts as the key acme-admin in the org acme. ${instructionsFor('own org', { orgTools, playbookTools }, [{ type: 'notes', noun: 'note', guide: 'notebook' }])}`, ); - expect(instructions).toContain("This connection acts in the caller's own org."); + expect(instructions).toContain("The org is the caller's own."); }); }); diff --git a/packages/api/src/mcp/mcp-server-factory.ts b/packages/api/src/mcp/mcp-server-factory.ts index 3e7996b96..376ef3fb3 100644 --- a/packages/api/src/mcp/mcp-server-factory.ts +++ b/packages/api/src/mcp/mcp-server-factory.ts @@ -122,9 +122,15 @@ interface ConnectedCall { } function serverWithTools(connected: ConnectedCall, served: ServedTools, tools: readonly Tool[]): McpServer { - return serverOf(connected.connection, served, connected.serving.linkedAnswers.states, (server) => { + const { connection, serving, call } = connected; + const session = { + connection, + actingAs: { caller: call.caller, org: call.org }, + states: serving.linkedAnswers.states, + }; + return serverOf(session, served, (server) => { for (const tool of tools) { - server.registerTool(tool.name, tool.definition, toolCallbackOf(connected.serving, connected.call, tool)); + server.registerTool(tool.name, tool.definition, toolCallbackOf(serving, call, tool)); } }); } diff --git a/packages/api/src/mcp/mcp-sign-in.test.ts b/packages/api/src/mcp/mcp-sign-in.test.ts index 73899b65c..7eedf31cf 100644 --- a/packages/api/src/mcp/mcp-sign-in.test.ts +++ b/packages/api/src/mcp/mcp-sign-in.test.ts @@ -4,7 +4,9 @@ import { afterEach, describe, expect, it } from 'vitest'; import { issuerOnLoopback, signedInOnLoopback, type OnLoopback } from '../testing/signed-in.ts'; import { toolNamesIn } from '../testing/tool-listing.ts'; -const clients = { 'ada-agent': { secret: 'a-secret-only-ada-holds', claims: { sub: 'ada', org_id: 'acme' } } }; +const clients = { + 'ada-agent': { secret: 'a-secret-only-ada-holds', claims: { sub: 'ada', name: 'Ada Lovelace', org_id: 'acme' } }, +}; const opened: OnLoopback[] = []; @@ -13,7 +15,7 @@ afterEach(async () => { }); describe('an agent signing in through the authorization server the metadata names', () => { - it('connects with the SDK’s client, is challenged, gets a token of every scope asked for the resource and calls the tools', async () => { + it('connects with the SDK’s client, is challenged, gets a token of every scope asked for the resource, is told it acts as the person and calls the tools', async () => { const issuer = await issuerOnLoopback(clients); const runtime = await signedInOnLoopback(issuer.served); opened.push(issuer, runtime); @@ -25,6 +27,7 @@ describe('an agent signing in through the authorization server the metadata name }); await client.connect(new StreamableHTTPClientTransport(new URL(`${runtime.origin}/mcp`), { authProvider })); + const instructions = client.getInstructions(); const tools = await client.listTools(); await client.callTool({ name: 'add_note', arguments: { playbook: 'alpha', name: 'from-ada', text: 'signed in' } }); const notes = await client.callTool({ name: 'list_notes', arguments: { playbook: 'alpha' } }); @@ -32,6 +35,7 @@ describe('an agent signing in through the authorization server the metadata name const [, claims] = String(authProvider.tokens()?.access_token).split('.'); + expect(instructions).toMatch(/^This connection acts as Ada Lovelace \(person:ada\) in the org acme\. /u); expect(toolNamesIn(tools)).toContain('add_note'); expect(notes.structuredContent).toEqual({ notes: [{ name: 'from-ada', text: 'signed in' }] }); expect(JSON.parse(Buffer.from(String(claims), 'base64url').toString('utf8'))).toMatchObject({ diff --git a/packages/api/src/mcp/tool-calling-instructions.test.ts b/packages/api/src/mcp/tool-calling-instructions.test.ts index acbf6bde2..c1a60cc64 100644 --- a/packages/api/src/mcp/tool-calling-instructions.test.ts +++ b/packages/api/src/mcp/tool-calling-instructions.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { callerTheInstructionsHold } from '../bounds/served-bounds.ts'; import { playbookEndpoint, definitionTypes, @@ -9,13 +10,22 @@ import { queriesInsideAPlaybook, withConnections, } from '../testing/served-instructions.ts'; -import { instructionsFor } from './instructions.ts'; +import { instructionsActingAs, instructionsFor } from './instructions.ts'; describe('the length of the instructions with every tool', () => { - it('take 1,993 characters on /mcp, 1,993 on the org endpoint and 1,575 on the playbook endpoint', () => { + it('take 1,869 characters on /mcp, 1,866 on the org endpoint and 1,502 on the playbook endpoint after their opening line', () => { expect( everyEndpoint.map(([endpoint, served]) => instructionsFor(endpoint, served, definitionTypes).length), - ).toEqual([1993, 1575, 1993]); + ).toEqual([1866, 1502, 1869]); + }); + + it('take 1,999, 1,996 and 1,632 with the opening line of the person the bound holds, which takes 130 of them', () => { + expect( + everyEndpoint.map( + ([endpoint, served]) => + instructionsActingAs(callerTheInstructionsHold, endpoint, served, definitionTypes).length, + ), + ).toEqual([1996, 1632, 1999]); }); }); @@ -55,14 +65,14 @@ describe('what the instructions say of calling a tool', () => { instructionsFor('playbook', { orgTools: [], playbookTools: queriesInsideAPlaybook }, definitionTypes), ]; - expect(lengths.map(({ length }) => length)).toEqual([1498, 1498, 1615, 1458]); + expect(lengths.map(({ length }) => length)).toEqual([1412, 1412, 1522, 1389]); }); }); describe('the instructions of an endpoint that lists search and add_connection', () => { const connecting = [ - "A service a playbook will use is connected on this server, whatever connector the agent has of its own: asked to connect, link, sign in to or use one, call search with its words, then add_connection with the provider's name, which the person approves on the page it links.", - 'Say a service cannot be connected only when no provider or server matches.', + "A service a playbook will use is connected here, whatever connector you have: asked to connect, link, sign in to or use one, call search with its words, then add_connection with the provider's name; the person approves on the page it links.", + 'Say a service cannot be connected only if no provider or server matches.', ].join(' '); it('say after the sentence on the guides that a service is connected through search and add_connection, on /mcp and the org endpoint', () => { @@ -70,7 +80,7 @@ describe('the instructions of an endpoint that lists search and add_connection', `give it an overview. ${connecting} When the person approves`, ); expect(instructionsFor('org', withConnections(orgEndpoint), definitionTypes)).toContain( - `how each kind of definition is written. ${connecting} Say what was done`, + `the format of each definition. ${connecting} Say what was done`, ); }); @@ -85,3 +95,23 @@ describe('the instructions of an endpoint that lists search and add_connection', expect(notConnecting.filter((instructions) => /connected|add_connection/u.test(instructions))).toEqual([]); }); }); + +describe('what the instructions say of the guides', () => { + it("point to get_guide for a definition's format and ask for its overview on every endpoint that writes one, and name no recipe, which get_guide names", () => { + const writing = everyEndpoint.filter(([endpoint]) => endpoint !== 'org'); + + expect( + writing.filter( + ([endpoint, served]) => + !instructionsFor(endpoint, served, definitionTypes).includes( + "Read a definition's format with get_guide; give it an overview.", + ), + ), + ).toEqual([]); + expect( + everyEndpoint.filter(([endpoint, served]) => + instructionsFor(endpoint, served, definitionTypes).includes('recipe'), + ), + ).toEqual([]); + }); +}); diff --git a/packages/api/src/testing/api-calls.ts b/packages/api/src/testing/api-calls.ts index abdc659a9..ee39344d7 100644 --- a/packages/api/src/testing/api-calls.ts +++ b/packages/api/src/testing/api-calls.ts @@ -35,8 +35,8 @@ const admitEveryone: Authenticator = { resource: undefined, authenticate: () => Promise.resolve({ - own: { id: 'anyone', org: 'acme', permissions: allPermissions, playbooks: '*' }, - callerIn: (org) => ({ id: 'anyone', org, permissions: allPermissions, playbooks: '*' }), + own: { id: 'anyone', name: 'anyone', org: 'acme', permissions: allPermissions, playbooks: '*' }, + callerIn: (org) => ({ id: 'anyone', name: 'anyone', org, permissions: allPermissions, playbooks: '*' }), }), }; diff --git a/packages/api/src/testing/served-instructions.ts b/packages/api/src/testing/served-instructions.ts index b8eeacce8..9e09b616e 100644 --- a/packages/api/src/testing/served-instructions.ts +++ b/packages/api/src/testing/served-instructions.ts @@ -1,3 +1,5 @@ +import { allPermissions, type CallerIdentity } from '@on.auto/operations'; + import type { DefinitionType, McpEndpoint, RecipeCalls, ServedTools } from '../mcp/instructions.ts'; const managingPlaybooks = ['create_playbook', 'list_playbooks', 'update_playbook', 'retire_playbook']; @@ -53,6 +55,18 @@ export const ownOrg: ServedTools = { playbookTools: insideAPlaybook.filter((name) => !discovering.includes(name)), }; +export const signedInAda: CallerIdentity = { + id: 'person:user_2ada', + name: 'Ada Lovelace', + org: 'acme', + permissions: allPermissions, + playbooks: '*', +}; + +export const buildBot: CallerIdentity = { ...signedInAda, id: 'build-bot', name: 'build-bot' }; + +export const localDeveloper: CallerIdentity = { ...signedInAda, id: 'local', name: 'ada', org: 'local' }; + export const definitionTypes: readonly DefinitionType[] = [ { type: 'reasoning', noun: 'reasoning function', guide: 'reasoning-function' }, { type: 'interaction', noun: 'interaction function', guide: 'interaction-function' }, diff --git a/packages/api/src/testing/signed-in.ts b/packages/api/src/testing/signed-in.ts index 103591e0a..ff0bba7ce 100644 --- a/packages/api/src/testing/signed-in.ts +++ b/packages/api/src/testing/signed-in.ts @@ -49,6 +49,7 @@ export async function signedInServer(): Promise { authorization: `Bearer ${await issuer.sign({ iss: issuer.issuer, sub: 'ada', + name: 'Ada Lovelace', aud: publicUrl, iat: seconds, exp: seconds + 300, diff --git a/packages/api/src/tools/linked-answers.test.ts b/packages/api/src/tools/linked-answers.test.ts index 9f2470c7c..28fb3ec82 100644 --- a/packages/api/src/tools/linked-answers.test.ts +++ b/packages/api/src/tools/linked-answers.test.ts @@ -100,7 +100,7 @@ const serving: AnsweringServing = { const answer = { content: [{ type: 'text', text: 'Made it.' }], structuredContent: { page } } as const; function replaying(keptFor: Readonly>) { - const caller = { id: 'acme-admin', org: 'acme', permissions: [], playbooks: '*' } as const; + const caller = { id: 'acme-admin', name: 'acme-admin', org: 'acme', permissions: [], playbooks: '*' } as const; const kept = { tool: 'open_page', caller: 'acme-admin', org: 'acme', ...keptFor, answer }; return toolCallbackOf( serving, diff --git a/packages/connections/README.md b/packages/connections/README.md index 085085079..eaf4c5b58 100644 --- a/packages/connections/README.md +++ b/packages/connections/README.md @@ -14,10 +14,10 @@ The connections of an org: tool servers a person adds to the org through their a | `entry` | `url`, `document`, `base_url`, `headers`, `query_params`, `allowed`, `playbooks`, `record_content` and `request_id`, the fields of `ServerEntrySchema` of `@on.auto/tool-servers` that hold no credential, with descriptions of their own | | `credential` | One flat object, `CredentialSchema`: `kind`, `none`, `reference`, `secret`, `client_credentials` or `authorization`, and the fields its kind takes: `name` and `in`, `header`, `query` or `security`, where its value is sent, and for `in: header` an optional `scheme`, which the runtime writes before the value, for `secret`, and with them `variable`, the variable of the runtime that holds the value, for `reference`; `issuer`, an optional `client_id`, which the person gives on the page with the secret where it is left out, and an optional `scope` for `client_credentials`; `issuer`, and as its provider's way gives them `scope`, `parameters`, `authorization_endpoint`, `token_endpoint`, `revocation_endpoint` and `clients`, with `client_id` and `client_source`, the client the connection is made with, for `authorization`; none for `none` | | `status` | `pending`, `connected`, `broken` or `removed`; `pending` covers a connection whose consent is started and not finished | -| `added_by` | The id of the caller who added it, such as `person:` | +| `added_by` | The caller who added it, by `id`, such as `person:`, and `name`, which the fact that adds it, `connection_added`, holds | | `added_at` | When it was added | | `connected_at` | When it was connected, or `null` | -| `connected_by` | The id of the caller who connected it, approving it or giving its credential, from the context of `connection_connected`, or `null` | +| `connected_by` | The caller who connected it, approving it or giving its credential, by `id` and `name`, as `connection_connected` holds it, or `null` | | `granted_scope` | For a connection connected by consent, the scope its authorization server granted, its scopes separated by single spaces, which may be fewer than the credential's `scope` asked, as when the person unticks one on the provider's page; `null` for another kind, before the connection is connected, and where none was asked or granted | | `credential_ends_with` | The ending the secret store keeps of its secret, as the ending of a credential below says, or `null` | | `broken` | The sentence of why it is broken while it is, else `null` | @@ -43,7 +43,7 @@ The connections of an org live in one stream, `connections` relative to the org, There is no read model: each call folds the stream (`connectionsFold`, the fold of `registryDecider`). The fold keeps, by id, the record, the open link, the open start of a consent, `consent`, with its `started_at` from the context of its fact, which `connection_connected` drops and a later start replaces, the id of its secret, and the algorithm of a private key; it keeps the secret's id and the start's `verifier_secret_id` after a removal, so the removal can forget both. It sets the `client_id` and the `client_source` of `connection_connected` on the record's credential, so a connection of consent says the client it was made with, and its `scope` on the record as `granted_scope`, beside the scope the credential keeps as its way asked it, so a connection the person allowed less than its way asked shows it; a connection connected holds one shape of credential, its client id given with the credential or on the page, and a pending one whose credential leaves its client id out is answered without it. It answers the connections newest first. `connection_connected` closes the link, and so does `connection_removed`. A pure decider holds the rules, and the handlers pass it who and when in the command: - `add` refuses a name a connection of the org holds that is not removed, `conflict` of the kind `taken`, and a connection past the 50 an org may hold that are not removed (`mostConnectionsOfAnOrg`), `conflict` of the kind `connections_full`, `The org holds 50 connections, the most it may; remove one before adding another`, which bounds the links the tool access keeps for the org, and records the connection and its link. -- `approve` and `give` look the connection up, `not_found`, then the link: open while the connection is pending, its digest the token's and `expires_at` later than now, else `not_found`, `There is no open link of this token: it is unknown, used up or expired`. Where people sign in, the caller must be `added_by`, else `forbidden`; `makeConnectionOperations` hands the two whether a link binds a person, which it does where `access` is `people`. `approve` connects a connection of the credential `none` or `reference`, recording `connection_approved` and `connection_connected`, and refuses one that takes a value with `conflict`; `give` records the two with the secret's id, and refuses a connection that takes none with `conflict`. Both refuse a connection whose credential is `authorization` with `conflict`, `The connection drive is connected by signing in to its provider and allowing Auto, which its page starts`, and `give_credential` refuses it so before it keeps anything. +- `approve` and `give` look the connection up, `not_found`, then the link: open while the connection is pending, its digest the token's and `expires_at` later than now, else `not_found`, `There is no open link of this token: it is unknown, used up or expired`. Where people sign in, the caller must be the one `added_by` names, by id, whatever name they go by now, else `forbidden`; `makeConnectionOperations` hands the two whether a link binds a person, which it does where `access` is `people`. `approve` connects a connection of the credential `none` or `reference`, recording `connection_approved` and `connection_connected`, and refuses one that takes a value with `conflict`; `give` records the two with the secret's id, and refuses a connection that takes none with `conflict`. Both refuse a connection whose credential is `authorization` with `conflict`, `The connection drive is connected by signing in to its provider and allowing Auto, which its page starts`, and `give_credential` refuses it so before it keeps anything. - `start` and `finish` take a connection whose credential is `authorization` and refuse another kind with `conflict`, `The connection crm is not connected by signing in to its provider, so it has no consent to start or finish`. `start` checks the link as `approve` does and records `connection_consent_started`. `finish` checks the link's token and its binding but not its own `expires_at`, since the start's ten minutes govern: a connection with no open start, none recorded or one whose `expires_at` is not later than now, is `not_found`, `There is no open consent of the connection drive: none was started, or it was started more than ten minutes ago`, a state whose digest is not the start's is `invalid_input` at `/state`, `Expected the state the consent was started with`, and otherwise it records `connection_approved` and `connection_connected` in one decision, as `give` does. - `remove` records `connection_removed`, and nothing for a connection already removed. - `break` records `connection_broken` with its `reason` for a connection that is connected, and nothing for one that is not, so a credential two calls find gone at once breaks its connection once. Only the source of [The calls](#the-calls) issues it, as the runtime, `by: runtime`. Every failure to record a `break`, a change that came first among them, is logged, `logError` of Effect with the cause, and the connection is refused broken all the same; the decider's `not_found` cannot arise, since the source reads the connection from the stream it records the break on and the fold never drops one. The source keeps nothing of a break and the tool access keeps no broken answer, so the next use reads the credential again and records the break again. diff --git a/packages/connections/src/operations/add-connection.test.ts b/packages/connections/src/operations/add-connection.test.ts index 18d36c427..3db5d76b6 100644 --- a/packages/connections/src/operations/add-connection.test.ts +++ b/packages/connections/src/operations/add-connection.test.ts @@ -21,7 +21,7 @@ describe('the answer to adding a connection', () => { }, credential: { kind: 'secret', name: 'Authorization', in: 'header' }, status: 'pending', - added_by: 'person:ada', + added_by: { id: 'person:ada', name: 'Ada Lovelace' }, added_at: '2026-10-15T14:00:00.000Z', connected_at: null, connected_by: null, diff --git a/packages/connections/src/operations/add-connection.ts b/packages/connections/src/operations/add-connection.ts index b2fb68ad9..b02d9897f 100644 --- a/packages/connections/src/operations/add-connection.ts +++ b/packages/connections/src/operations/add-connection.ts @@ -8,7 +8,7 @@ import { checkedAdditionOf, refusalOfTheAddition } from '../providers/provider-a import { AddedSchema, AdditionSchema } from './connection-fields.ts'; import type { ConnectionServices } from './connection-services.ts'; import { connectionNamed, linkWordsOf } from './connection-words.ts'; -import { additionOf, recordInRegistry } from './registry-access.ts'; +import { additionOf, recordInRegistry, recordedCaller } from './registry-access.ts'; const description = [ "Starts connecting a person's account at a service, by the name of a provider that search lists, or a server given by hand as name, type, entry and credential.", @@ -47,7 +47,7 @@ export function defineAddConnection({ return yield* Effect.fail(refusal); } const { token, ...link } = yield* issuedLink; - const addition = additionOf(given, link); + const addition = additionOf(given, link, yield* recordedCaller); const kept = yield* recordInRegistry(addition).pipe( Effect.catchTags({ not_found: Effect.die, forbidden: Effect.die, invalid_input: Effect.die }), ); diff --git a/packages/connections/src/operations/registry-access.ts b/packages/connections/src/operations/registry-access.ts index b6297257f..8d436e35e 100644 --- a/packages/connections/src/operations/registry-access.ts +++ b/packages/connections/src/operations/registry-access.ts @@ -1,4 +1,12 @@ -import { Caller, OrgReader, OrgWriter, randomUUIDv7, type NotFound } from '@on.auto/operations'; +import { + Caller, + OrgReader, + OrgWriter, + randomUUIDv7, + recordedCallerOf, + type NotFound, + type RecordedCaller, +} from '@on.auto/operations'; import { DateTime, Effect } from 'effect'; import type { GivenAddition } from '../providers/provider-additions.ts'; @@ -16,6 +24,8 @@ export function foundConnection(id: string): Effect.Effect findConnection(registry, id))); } +export const recordedCaller = Effect.map(Effect.service(Caller), recordedCallerOf); + export const actedNow = Effect.gen(function* () { const { id: by } = yield* Caller; return { by, at: DateTime.formatIso(yield* DateTime.now) }; @@ -24,6 +34,7 @@ export const actedNow = Effect.gen(function* () { export function additionOf( { name, type, provider, placeholders, entry, credential }: GivenAddition, link: OpenLink, + addedBy: RecordedCaller, ): ConnectionAddition { return { type: 'add', @@ -34,6 +45,7 @@ export function additionOf( placeholders, entry, credential, + added_by: addedBy, link, }; } diff --git a/packages/connections/src/page/consent-finish.test.ts b/packages/connections/src/page/consent-finish.test.ts index fc2cbcf0a..43213ea69 100644 --- a/packages/connections/src/page/consent-finish.test.ts +++ b/packages/connections/src/page/consent-finish.test.ts @@ -64,6 +64,7 @@ describe('the finish of a consent', () => { client_id: 'auto-runtime', client_source: 'registered', scope: 'files.read', + connected_by: { id: 'person:ada', name: 'Ada Lovelace' }, }, ], ]); diff --git a/packages/connections/src/page/consent-finish.ts b/packages/connections/src/page/consent-finish.ts index 6be0bbbb8..0d08c1192 100644 --- a/packages/connections/src/page/consent-finish.ts +++ b/packages/connections/src/page/consent-finish.ts @@ -3,7 +3,7 @@ import { Effect, Schema } from 'effect'; import { tokenSha256Of } from '../links/link-token.ts'; import { ConnectionIdField, TokenField } from '../operations/connection-fields.ts'; -import { actedNow, loadRegistry, recordInRegistry } from '../operations/registry-access.ts'; +import { actedNow, loadRegistry, recordInRegistry, recordedCaller } from '../operations/registry-access.ts'; import { ConnectionSchema, connectionShown } from '../registry/connection.ts'; import { finishedConnectionOf } from '../registry/registry-decisions.ts'; import { noOpenConsent, type KeptWithConsent } from '../registry/registry-lookup.ts'; @@ -96,19 +96,24 @@ function redeemedOf( }); } -function recordedWith(finished: FinishedConsent, secretId: string, { use, stateSha256 }: Recording) { +const recordedWith = Effect.fnUntraced(function* ( + finished: FinishedConsent, + secretId: string, + { use, stateSha256 }: Recording, +) { const { client_id: clientId, client_source: clientSource } = finished.client; const granted = finished.scope === undefined ? {} : { scope: finished.scope }; - return recordInRegistry({ + return yield* recordInRegistry({ type: 'finish', ...use, + connected_by: yield* recordedCaller, state_sha256: stateSha256, secret_id: secretId, client_id: clientId, client_source: clientSource, ...granted, }); -} +}); function connectedWith(finished: FinishedConsent, recording: Recording, { secrets }: ConsentServices) { const named = namedOf(recording.kept.connection); diff --git a/packages/connections/src/page/give-credential.test.ts b/packages/connections/src/page/give-credential.test.ts index 5091d252b..262856767 100644 --- a/packages/connections/src/page/give-credential.test.ts +++ b/packages/connections/src/page/give-credential.test.ts @@ -3,13 +3,15 @@ import { describe, expect, it } from 'vitest'; import { connectionPolicyOf } from '../policy/connection-policy.ts'; import { makeConnectionSource } from '../serving/connection-source.ts'; -import { added, billing, crm, issuesIn, notes } from '../testing/additions.ts'; -import { ada, grace, harness, operationNamed, type Harness, fixtureServing } from '../testing/harness.ts'; +import { added, billing, crm, issuesIn, lastFact, notes } from '../testing/additions.ts'; +import { ada, grace, harness, operationNamed, recordedAs, type Harness, fixtureServing } from '../testing/harness.ts'; function giving(h: Harness, input: Readonly>, caller = ada, at?: string) { return h.call(operationNamed(h.operations, 'give_credential'), caller, input, at); } +const byAda = { connected_by: recordedAs.ada }; + describe('giving the credential of a connection', () => { it('keeps exactly its value in the secret store, records its id, approves the connection and uses the link up', async () => { const h = harness(); @@ -23,7 +25,7 @@ describe('giving the credential of a connection', () => { const facts = await h.facts(); expect(facts.slice(2).map(({ type, data }) => [type, data])).toEqual([ ['connection_approved', { connection: id }], - ['connection_connected', { connection: id, secret_id: 'secret-1' }], + ['connection_connected', { connection: id, secret_id: 'secret-1', ...byAda }], ]); expect(JSON.stringify(facts)).not.toContain('crm-0f3c'); expect(await giving(h, { id, token, value: 'Bearer crm-again' })).toMatchObject({ reason: 'not_found' }); @@ -34,14 +36,10 @@ describe('giving the credential of a connection', () => { const { id, token } = await added(h, crm); expect(await giving(h, { id, token, value: 'Bearer crm-0f3c' }, grace, '2026-10-15T14:04:00.000Z')).toMatchObject({ - output: { - added_by: 'person:ada', - connected_at: '2026-10-15T14:04:00.000Z', - connected_by: 'person:grace', - }, + output: { added_by: recordedAs.ada, connected_at: '2026-10-15T14:04:00.000Z', connected_by: recordedAs.grace }, }); expect(await h.call(operationNamed(h.operations, 'get_connection'), ada, { id })).toMatchObject({ - output: { connected_by: 'person:grace' }, + output: { connected_by: recordedAs.grace }, }); }); @@ -54,7 +52,7 @@ describe('giving the credential of a connection', () => { await giving(h, { id: key.id, token: key.token, private_key: '-----BEGIN PRIVATE KEY-----', algorithm: 'ES256' }); expect([...h.secrets.values().values()]).toEqual(['billing-client-secret', '-----BEGIN PRIVATE KEY-----']); - expect((await h.facts()).at(-1)?.data).toEqual({ connection: key.id, secret_id: 'secret-2', algorithm: 'ES256' }); + expect(await lastFact(h)).toEqual({ connection: key.id, secret_id: 'secret-2', algorithm: 'ES256', ...byAda }); }); }); @@ -170,7 +168,7 @@ describe('client credentials whose client id the connection does not hold', () = const given = await giving(h, { id, token, client_id: 'acme', client_secret: 'billing-client-secret' }); expect(given).toMatchObject({ status: 'succeeded', output: { credential: billing.credential } }); - expect((await h.facts()).at(-1)?.data).toEqual({ connection: id, secret_id: 'secret-1', client_id: 'acme' }); + expect(await lastFact(h)).toEqual({ connection: id, secret_id: 'secret-1', client_id: 'acme', ...byAda }); expect(await h.call(operationNamed(h.operations, 'get_connection'), ada, { id })).toMatchObject({ output: { status: 'connected', credential: billing.credential }, }); @@ -182,10 +180,11 @@ describe('client credentials whose client id the connection does not hold', () = await giving(h, { id, token, client_id: 'acme', private_key: '-----BEGIN PRIVATE KEY-----', algorithm: 'ES256' }); - expect((await h.facts()).at(-1)?.data).toEqual({ + expect(await lastFact(h)).toEqual({ connection: id, secret_id: 'secret-1', algorithm: 'ES256', + ...byAda, client_id: 'acme', }); }); diff --git a/packages/connections/src/page/give-credential.ts b/packages/connections/src/page/give-credential.ts index 45f386fba..101a7fa78 100644 --- a/packages/connections/src/page/give-credential.ts +++ b/packages/connections/src/page/give-credential.ts @@ -3,7 +3,7 @@ import { Effect, Redacted } from 'effect'; import { tokenSha256Of } from '../links/link-token.ts'; import type { ConnectionServices } from '../operations/connection-services.ts'; -import { actedNow, loadRegistry, recordInRegistry } from '../operations/registry-access.ts'; +import { actedNow, loadRegistry, recordInRegistry, recordedCaller } from '../operations/registry-access.ts'; import { providingOf } from '../providers/provider-additions.ts'; import { ConnectionSchema, connectionShown } from '../registry/connection.ts'; import { linkedConnectionOf } from '../registry/registry-lookup.ts'; @@ -48,6 +48,7 @@ export function defineGiveCredential({ const connected = yield* recordInRegistry({ type: 'give', ...use, + connected_by: yield* recordedCaller, secret_id: secretId, ...algorithm, ...client, diff --git a/packages/connections/src/page/link-operations.test.ts b/packages/connections/src/page/link-operations.test.ts index 375a5fe03..f535d48d1 100644 --- a/packages/connections/src/page/link-operations.test.ts +++ b/packages/connections/src/page/link-operations.test.ts @@ -97,7 +97,7 @@ describe('approving a connection', () => { expect(await approving(h, id, token)).toEqual(noOpenLink); expect((await h.facts()).slice(4, 6).map(({ type, data }) => [type, data])).toEqual([ ['connection_approved', { connection: id }], - ['connection_connected', { connection: id }], + ['connection_connected', { connection: id, connected_by: { id: 'person:ada', name: 'Ada Lovelace' } }], ]); }); @@ -126,3 +126,14 @@ describe('approving a connection', () => { expect(await approving(local, here.id, here.token, grace)).toMatchObject({ output: { status: 'connected' } }); }); }); + +describe('the person who may approve a connection where sign-in is on', () => { + it('is the person who added it by their id, whatever name they sign in with now', async () => { + const h = harness(); + const { id, token } = await added(h, notes); + + expect(await approving(h, id, token, { ...ada, name: 'Ada King' })).toMatchObject({ + output: { status: 'connected', added_by: { id: 'person:ada', name: 'Ada Lovelace' } }, + }); + }); +}); diff --git a/packages/connections/src/page/link-operations.ts b/packages/connections/src/page/link-operations.ts index 8c7c398c6..b911c946d 100644 --- a/packages/connections/src/page/link-operations.ts +++ b/packages/connections/src/page/link-operations.ts @@ -5,7 +5,7 @@ import { Effect, Schema } from 'effect'; import { studioBaseFor, type ConnectPages } from '../links/connect-page.ts'; import { tokenSha256Of } from '../links/link-token.ts'; import { ClientSourceField, ConnectionIdField, ExpiresAtField, TokenField } from '../operations/connection-fields.ts'; -import { actedNow, loadRegistry, recordInRegistry } from '../operations/registry-access.ts'; +import { actedNow, loadRegistry, recordInRegistry, recordedCaller } from '../operations/registry-access.ts'; import { ConnectionSchema, connectionShown, type RecordedConnection } from '../registry/connection.ts'; import { isOpenConsent, linkedConnectionOf } from '../registry/registry-lookup.ts'; import { wayOf } from './consent-asking.ts'; @@ -100,12 +100,14 @@ export function defineApprove({ bindsPerson }: { readonly bindsPerson: boolean } inputSchema: LinkSchema, answer: answeringTheRecord, reasons: ['not_found', 'forbidden', 'conflict'], - handle: ({ id, token }) => - Effect.map( - recordInRegistry({ type: 'approve', connection: id, token_sha256: tokenSha256Of(token), bindsPerson }).pipe( - Effect.catchTag('invalid_input', Effect.die), - ), - ({ connection }) => connectionShown(connection, null), - ), + handle: Effect.fnUntraced(function* ({ id, token }) { + const use = { connection: id, token_sha256: tokenSha256Of(token), bindsPerson }; + const { connection } = yield* recordInRegistry({ + type: 'approve', + ...use, + connected_by: yield* recordedCaller, + }).pipe(Effect.catchTag('invalid_input', Effect.die)); + return connectionShown(connection, null); + }), }); } diff --git a/packages/connections/src/policy/who-adds.test.ts b/packages/connections/src/policy/who-adds.test.ts index c6e0f50ca..7697c575a 100644 --- a/packages/connections/src/policy/who-adds.test.ts +++ b/packages/connections/src/policy/who-adds.test.ts @@ -4,9 +4,9 @@ import { describe, expect, it } from 'vitest'; import { adding, notes } from '../testing/additions.ts'; import { ada, harness } from '../testing/harness.ts'; -const acmeBuilder: CallerIdentity = { ...ada, id: 'acme-builder' }; +const acmeBuilder: CallerIdentity = { ...ada, id: 'acme-builder', name: 'acme-builder' }; -const localDeveloper: CallerIdentity = { ...ada, id: 'local' }; +const localDeveloper: CallerIdentity = { ...ada, id: 'local', name: 'ada' }; describe('who adds a connection', () => { it('is a person on a runtime that signs people in, and never a key', async () => { @@ -17,7 +17,10 @@ describe('who adds a connection', () => { reason: 'forbidden', detail: 'A connection is added by a person, through an agent signed in as them, and a key cannot add one', }); - expect(await adding(h, notes)).toMatchObject({ status: 'succeeded', output: { added_by: 'person:ada' } }); + expect(await adding(h, notes)).toMatchObject({ + status: 'succeeded', + output: { added_by: { id: 'person:ada', name: 'Ada Lovelace' } }, + }); }); it('is nobody on a runtime of keys alone, which signs in no person to approve one', async () => { @@ -37,7 +40,7 @@ describe('who adds a connection', () => { expect(await adding(h, notes, localDeveloper)).toMatchObject({ status: 'succeeded', - output: { added_by: 'local' }, + output: { added_by: { id: 'local', name: 'ada' } }, }); }); }); diff --git a/packages/connections/src/registry/connection-commands.ts b/packages/connections/src/registry/connection-commands.ts index 9cafba836..ee7234307 100644 --- a/packages/connections/src/registry/connection-commands.ts +++ b/packages/connections/src/registry/connection-commands.ts @@ -1,3 +1,4 @@ +import type { RecordedCaller } from '@on.auto/operations'; import type { ClientSource } from '@on.auto/tool-servers'; import type { ConnectionEntry, ConnectionType } from './connection-entry.ts'; @@ -13,6 +14,7 @@ export interface ConnectionAddition { readonly placeholders: Readonly> | null; readonly entry: ConnectionEntry; readonly credential: Credential; + readonly added_by: RecordedCaller; readonly link: OpenLink; } @@ -22,11 +24,15 @@ export interface LinkUse { readonly bindsPerson: boolean; } -export interface ConnectionApproval extends LinkUse { +interface Connecting extends LinkUse { + readonly connected_by: RecordedCaller; +} + +export interface ConnectionApproval extends Connecting { readonly type: 'approve'; } -export interface CredentialGiving extends LinkUse { +export interface CredentialGiving extends Connecting { readonly type: 'give'; readonly secret_id: string; readonly algorithm?: string; @@ -42,7 +48,7 @@ export interface ConsentStart extends LinkUse { readonly expires_at: string; } -export interface ConsentFinish extends LinkUse { +export interface ConsentFinish extends Connecting { readonly type: 'finish'; readonly state_sha256: string; readonly secret_id: string; diff --git a/packages/connections/src/registry/connection-events.ts b/packages/connections/src/registry/connection-events.ts index 50192b2fc..44f37e699 100644 --- a/packages/connections/src/registry/connection-events.ts +++ b/packages/connections/src/registry/connection-events.ts @@ -1,4 +1,4 @@ -import { ActedContextSchema, factOf, type Decided } from '@on.auto/operations'; +import { ActedContextSchema, RecordedCallerSchema, factOf, type Decided } from '@on.auto/operations'; import { ClientSourceSchema } from '@on.auto/tool-servers'; import { Schema } from 'effect'; @@ -18,6 +18,7 @@ export const ConnectionEventSchema = Schema.Union([ placeholders: Schema.NullOr(Schema.Record(Schema.String, Schema.String)), entry: ConnectionEntrySchema, credential: CredentialSchema, + added_by: RecordedCallerSchema, }), ActedContextSchema, ), @@ -48,6 +49,7 @@ export const ConnectionEventSchema = Schema.Union([ client_id: Schema.optionalKey(Schema.String), client_source: Schema.optionalKey(ClientSourceSchema), scope: Schema.optionalKey(Schema.String), + connected_by: RecordedCallerSchema, }), ActedContextSchema, ), diff --git a/packages/connections/src/registry/connection.ts b/packages/connections/src/registry/connection.ts index 05b52ebb5..4ccd966b3 100644 --- a/packages/connections/src/registry/connection.ts +++ b/packages/connections/src/registry/connection.ts @@ -1,3 +1,4 @@ +import { RecordedCallerSchema } from '@on.auto/operations'; import { Schema } from 'effect'; import { ConnectionEntrySchema, ConnectionTypeSchema } from './connection-entry.ts'; @@ -20,11 +21,11 @@ export const ConnectionSchema = Schema.Struct({ status: ConnectionStatusSchema.annotate({ description: 'pending until it is approved and its credential given, connected, broken or removed', }), - added_by: Schema.String.annotate({ description: 'The id of the caller who added it' }), + added_by: RecordedCallerSchema.annotate({ description: 'The caller who added it, by id and name' }), added_at: Schema.String.annotate({ description: 'When it was added, in ISO 8601 UTC' }), connected_at: Schema.NullOr(Schema.String).annotate({ description: 'When it was connected, in ISO 8601 UTC' }), - connected_by: Schema.NullOr(Schema.String).annotate({ - description: 'The id of the caller who connected it, approving it or giving its credential', + connected_by: Schema.NullOr(RecordedCallerSchema).annotate({ + description: 'The caller who connected it, approving it or giving its credential, by id and name', }), granted_scope: Schema.NullOr(Schema.String).annotate({ description: diff --git a/packages/connections/src/registry/consent-decisions.test.ts b/packages/connections/src/registry/consent-decisions.test.ts index 2fa1ed2c2..ff748b4a3 100644 --- a/packages/connections/src/registry/consent-decisions.test.ts +++ b/packages/connections/src/registry/consent-decisions.test.ts @@ -20,6 +20,7 @@ const driveAdded: ConnectionEvent = { placeholders: {}, entry: { url: 'https://mcp.drive.example.com/mcp' }, credential: { kind: 'authorization', issuer: 'https://accounts.drive.example.com' }, + added_by: { id: 'person:ada', name: 'Ada Lovelace' }, }, }; @@ -46,6 +47,7 @@ describe('a credential given to a connection of consent', () => { connection: 'drive-id', token_sha256: 'a1b2', bindsPerson: true, + connected_by: { id: 'person:ada', name: 'Ada Lovelace' }, secret_id: 'secret-1', ...acted, }; @@ -71,6 +73,7 @@ const finishingDrive: ConnectionCommand = { connection: 'drive-id', token_sha256: 'a1b2', bindsPerson: true, + connected_by: { id: 'person:ada', name: 'Ada Lovelace' }, state_sha256: 'c3d4', secret_id: 'secret-2', client_id: 'auto-runtime', @@ -91,6 +94,7 @@ describe('the finish of a consent, as the decider takes it', () => { client_id: 'auto-runtime', client_source: 'registered', scope: 'files.read', + connected_by: { id: 'person:ada', name: 'Ada Lovelace' }, }, }, ]), @@ -129,7 +133,13 @@ describe('the fold of a consent', () => { const removed: ConnectionEvent = { type: 'connection_removed', data: { connection: 'drive-id' } }; const connectedDrive: ConnectionEvent = { type: 'connection_connected', - data: { connection: 'drive-id', secret_id: 'secret-2', client_id: 'auto-runtime', client_source: 'registered' }, + data: { + connection: 'drive-id', + secret_id: 'secret-2', + client_id: 'auto-runtime', + client_source: 'registered', + connected_by: { id: 'person:ada', name: 'Ada Lovelace' }, + }, }; expect([ fold(driveAdded, linkIssued, started)?.consent, @@ -176,6 +186,7 @@ const grantedTheRead: ConnectionEvent = { client_id: 'auto-runtime', client_source: 'registered', scope: 'files.read', + connected_by: { id: 'person:ada', name: 'Ada Lovelace' }, }, }; diff --git a/packages/connections/src/registry/registry-decider.test.ts b/packages/connections/src/registry/registry-decider.test.ts index 86482c932..cf7d7a363 100644 --- a/packages/connections/src/registry/registry-decider.test.ts +++ b/packages/connections/src/registry/registry-decider.test.ts @@ -21,6 +21,7 @@ const crmAdded: ConnectionEvent = { placeholders: null, entry: { url: 'https://crm.example.com/mcp' }, credential: { kind: 'none' }, + added_by: { id: 'person:ada', name: 'Ada Lovelace' }, }, }; @@ -43,6 +44,7 @@ describe('giving a credential to a connection that takes none', () => { connection: 'crm-id', token_sha256: 'a1b2', bindsPerson: true, + connected_by: { id: 'person:ada', name: 'Ada Lovelace' }, secret_id: 'secret-1', ...acted, }; @@ -59,7 +61,10 @@ describe('the stream of the connections of an org', () => { }); }); -const connected: ConnectionEvent = { type: 'connection_connected', data: { connection: 'crm-id' } }; +const connected: ConnectionEvent = { + type: 'connection_connected', + data: { connection: 'crm-id', connected_by: { id: 'person:ada', name: 'Ada Lovelace' } }, +}; const breaking: ConnectionCommand = { type: 'break', @@ -101,6 +106,7 @@ function addedAs(index: number): ConnectionEvent { placeholders: null, entry: { url: 'https://crm.example.com/mcp' }, credential: { kind: 'none' }, + added_by: { id: 'person:ada', name: 'Ada Lovelace' }, }, }; } @@ -117,6 +123,7 @@ describe('adding a connection to an org that holds the most it may', () => { placeholders: null, entry: { url: 'https://wiki.example.com/mcp' }, credential: { kind: 'none' }, + added_by: { id: 'person:ada', name: 'Ada Lovelace' }, link: { token_sha256: 'c3d4', expires_at: '2026-10-15T14:10:00.000Z' }, ...acted, }; diff --git a/packages/connections/src/registry/registry-decisions.ts b/packages/connections/src/registry/registry-decisions.ts index 3bc3206d2..d9bbae66e 100644 --- a/packages/connections/src/registry/registry-decisions.ts +++ b/packages/connections/src/registry/registry-decisions.ts @@ -90,7 +90,7 @@ function decideStart(command: ConsentStart & CommandMetadata, registry: Registry } function decideFinish(command: ConsentFinish & CommandMetadata, registry: Registry): Decision { - const { connection, secret_id, client_id, client_source, scope } = command; + const { connection, secret_id, client_id, client_source, scope, connected_by } = command; const granted = scope === undefined ? {} : { scope }; return Result.flatMap(finishedConnectionOf(registry, command, command), (kept): Decision => { if (!isOpenConsent(kept, command.at)) { @@ -100,7 +100,10 @@ function decideFinish(command: ConsentFinish & CommandMetadata, registry: Regist return stateIssue === undefined ? Result.succeed([ { type: 'connection_approved', data: { connection } }, - { type: 'connection_connected', data: { connection, secret_id, client_id, client_source, ...granted } }, + { + type: 'connection_connected', + data: { connection, secret_id, client_id, client_source, ...granted, connected_by }, + }, ]) : Result.fail(stateIssue); }); @@ -133,8 +136,9 @@ function addedData({ placeholders, entry, credential, + added_by: addedBy, }: Omit) { - return { connection, name, server_type: serverType, provider, placeholders, entry, credential }; + return { connection, name, server_type: serverType, provider, placeholders, entry, credential, added_by: addedBy }; } function decideApproval(command: ConnectionApproval & CommandMetadata, registry: Registry): Decision { @@ -150,13 +154,16 @@ function decideApproval(command: ConnectionApproval & CommandMetadata, registry: ) : Result.succeed([ { type: 'connection_approved', data: { connection: command.connection } }, - { type: 'connection_connected', data: { connection: command.connection } }, + { + type: 'connection_connected', + data: { connection: command.connection, connected_by: command.connected_by }, + }, ]); }); } function decideGiving(command: CredentialGiving & CommandMetadata, registry: Registry): Decision { - const { connection, secret_id: secretId, algorithm, client_id: clientId } = command; + const { connection, secret_id: secretId, algorithm, client_id: clientId, connected_by: connectedBy } = command; const signing = algorithm === undefined ? {} : { algorithm }; const client = clientId === undefined ? {} : { client_id: clientId }; return Result.flatMap(linkedConnectionOf(registry, command, command), (kept): Decision => { @@ -166,7 +173,10 @@ function decideGiving(command: CredentialGiving & CommandMetadata, registry: Reg return takesAValue(kept) ? Result.succeed([ { type: 'connection_approved', data: { connection } }, - { type: 'connection_connected', data: { connection, secret_id: secretId, ...signing, ...client } }, + { + type: 'connection_connected', + data: { connection, secret_id: secretId, ...signing, ...client, connected_by: connectedBy }, + }, ]) : Result.fail(takesNoCredential(kept.connection.name)); }); diff --git a/packages/connections/src/registry/registry-lookup.ts b/packages/connections/src/registry/registry-lookup.ts index d55a76b0c..51b42f290 100644 --- a/packages/connections/src/registry/registry-lookup.ts +++ b/packages/connections/src/registry/registry-lookup.ts @@ -53,7 +53,7 @@ function boundConnectionOf( if (!isUsable(kept, tokenSha256)) { return Result.fail(noOpenLink); } - return bindsPerson && kept.connection.added_by !== by + return bindsPerson && kept.connection.added_by.id !== by ? Result.fail( new Forbidden({ detail: `The connection ${kept.connection.name} was added by another person, and only they may approve it`, diff --git a/packages/connections/src/registry/registry.test.ts b/packages/connections/src/registry/registry.test.ts index cb156ecc8..bc1e4de1c 100644 --- a/packages/connections/src/registry/registry.test.ts +++ b/packages/connections/src/registry/registry.test.ts @@ -19,6 +19,7 @@ const crm = { placeholders: null, entry: { url: 'https://crm.example.com/mcp', headers: { 'X-Tenant': 'acme-eu' } }, credential: { kind: 'secret', name: 'Authorization', in: 'header' }, + added_by: { id: 'person:ada', name: 'Ada Lovelace' }, } as const; const crmAdded: ConnectionEvent = { type: 'connection_added', data: crm }; @@ -35,7 +36,7 @@ const approval: ConnectionEvent = { type: 'connection_approved', data: { connect const connection: ConnectionEvent = { type: 'connection_connected', - data: { connection: '0199-crm', secret_id: 'secret-1' }, + data: { connection: '0199-crm', secret_id: 'secret-1', connected_by: { id: 'person:grace', name: 'Grace Hopper' } }, }; function evolvedWith(registry: Registry, event: ConnectionEvent, context: ActedContext): Registry { @@ -48,7 +49,7 @@ function crmAfter(...events: readonly ConnectionEvent[]) { } describe('an added connection, as the stream folds it', () => { - it('is pending, added by the caller of the fact, with its link open', () => { + it('is pending, added by the caller its fact names, by id and name, with its link open', () => { expect(crmAfter()).toEqual({ connection: { id: '0199-crm', @@ -59,7 +60,7 @@ describe('an added connection, as the stream folds it', () => { entry: { url: 'https://crm.example.com/mcp', headers: { 'X-Tenant': 'acme-eu' } }, credential: { kind: 'secret', name: 'Authorization', in: 'header' }, status: 'pending', - added_by: 'person:ada', + added_by: { id: 'person:ada', name: 'Ada Lovelace' }, added_at: '2026-10-15T14:00:00.000Z', connected_at: null, connected_by: null, @@ -78,7 +79,7 @@ describe('an added connection, as the stream folds it', () => { connection: { status: 'connected', connected_at: '2026-10-15T14:05:00.000Z', - connected_by: 'person:grace', + connected_by: { id: 'person:grace', name: 'Grace Hopper' }, broken: null, }, link: undefined, @@ -138,11 +139,17 @@ describe('a connection of client credentials whose client id is given on the pag placeholders: null, entry: { document: 'https://billing.example.com/openapi.json' }, credential: { kind: 'client_credentials', issuer }, + added_by: { id: 'person:ada', name: 'Ada Lovelace' }, }, }; const connectedWithItsClient: ConnectionEvent = { type: 'connection_connected', - data: { connection: '0199-billing', secret_id: 'secret-2', client_id: 'acme' }, + data: { + connection: '0199-billing', + secret_id: 'secret-2', + client_id: 'acme', + connected_by: { id: 'person:grace', name: 'Grace Hopper' }, + }, }; expect([credentialAfter(billing), credentialAfter(billing, connectedWithItsClient)]).toEqual([ diff --git a/packages/connections/src/registry/registry.ts b/packages/connections/src/registry/registry.ts index 36d2e5290..0c2bc8dcf 100644 --- a/packages/connections/src/registry/registry.ts +++ b/packages/connections/src/registry/registry.ts @@ -38,7 +38,7 @@ export type Registry = ReadonlyMap; export const initialRegistry: Registry = new Map(); function addedConnection({ data, context }: ConnectionAdded): KeptConnection { - const { connection: id, name, server_type: type, provider, placeholders, entry, credential } = data; + const { connection: id, name, server_type: type, provider, placeholders, entry, credential, added_by } = data; return { connection: { id, @@ -49,7 +49,7 @@ function addedConnection({ data, context }: ConnectionAdded): KeptConnection { entry, credential, status: 'pending', - added_by: context.by, + added_by, added_at: context.at, connected_at: null, connected_by: null, @@ -92,7 +92,7 @@ function connected(kept: KeptConnection, event: ConnectionConnected): KeptConnec credential: credentialOf(kept, event), status: 'connected', connected_at: context.at, - connected_by: context.by, + connected_by: data.connected_by, granted_scope: data.scope ?? null, broken: null, }, diff --git a/packages/connections/src/testing/additions.ts b/packages/connections/src/testing/additions.ts index 01c42f0b0..c542bad49 100644 --- a/packages/connections/src/testing/additions.ts +++ b/packages/connections/src/testing/additions.ts @@ -54,6 +54,10 @@ const RejectedSchema = Schema.Struct({ issues: Schema.Array(Schema.Struct({ pointer: Schema.String, detail: Schema.String })), }); +export async function lastFact(h: Harness): Promise { + return (await h.facts()).at(-1)?.data; +} + export function namesIn(outcome: Outcome): readonly string[] { return Schema.decodeUnknownSync(ListedSchema)(outcome).output.connections.map(({ name }) => name); } diff --git a/packages/connections/src/testing/harness.ts b/packages/connections/src/testing/harness.ts index e0a732b65..1cbe4a34c 100644 --- a/packages/connections/src/testing/harness.ts +++ b/packages/connections/src/testing/harness.ts @@ -28,13 +28,26 @@ const studio = { origin: 'https://studio.on.auto', served: false }; const runtimeOrigin = 'http://localhost:5555'; -export const ada: CallerIdentity = { id: 'person:ada', org: 'acme', permissions: allPermissions, playbooks: '*' }; +export const ada: CallerIdentity = { + id: 'person:ada', + name: 'Ada Lovelace', + org: 'acme', + permissions: allPermissions, + playbooks: '*', +}; + +export const grace: CallerIdentity = { ...ada, id: 'person:grace', name: 'Grace Hopper' }; -export const grace: CallerIdentity = { ...ada, id: 'person:grace' }; +export const recordedAs = { ada: { id: ada.id, name: ada.name }, grace: { id: grace.id, name: grace.name } }; -export const acmeReader: CallerIdentity = { ...ada, id: 'acme-reader', permissions: ['org:read'] }; +export const acmeReader: CallerIdentity = { ...ada, id: 'acme-reader', name: 'acme-reader', permissions: ['org:read'] }; -export const acmeViewer: CallerIdentity = { ...ada, id: 'acme-viewer', permissions: ['playbook:read'] }; +export const acmeViewer: CallerIdentity = { + ...ada, + id: 'acme-viewer', + name: 'acme-viewer', + permissions: ['playbook:read'], +}; export const harnessPages = { studio, runtime: () => runtimeOrigin }; diff --git a/packages/definitions/README.md b/packages/definitions/README.md index add794405..e8cd2cecc 100644 --- a/packages/definitions/README.md +++ b/packages/definitions/README.md @@ -95,7 +95,7 @@ A call cancelled while `run` runs, because its client went away or the server is | `get_run_history` | query | `GET /runs/{run_id}/history` | `run_id`, `order` (default `asc`), `limit`, `cursor` | `{ events, has_more, next_cursor }` | `not_found`, `invalid_input` | | `get_playbook_analytics` | query | `GET /analytics` | `days` (7, 14 or 30, default 7), or `from` and `to`; `type`, `name`, all optional | the analytics of the playbook | `invalid_input` | -A definition carries `type`, `name`, `version`, `status` (`active` or `retired`), `media_type`, the `description`, `input_schema`, `output_schema` and `warnings` its capability gives when it gives them, `created_at`, `created_by`, `updated_at`, `retired_at` on a retired definition, its document as `source`, the `overview` its author gave with the version, when one was given (see [The overview of a definition](#the-overview-of-a-definition)), and `used_by`, the active definitions of the playbook that use it, when any does (see [Used by](#used-by)). `get_definition` adds `standing` when its capability's `standing` answers one (below). Of the `details` its capability gives, which the registry keeps, a definition answers only `details.shape`, the name of its [output shape](#output-shapes), when the details name one, and leaves the rest out (`src/operations/definition-views.ts`). A listed definition is the same without `source`, `overview`, `used_by` and `standing`. Times are ISO 8601 UTC strings read from Effect's `Clock`. `DefinitionSchema`, `ListedDefinitionSchema`, `RunSchema` and `RunDetailSchema` are the schemas. +A definition carries `type`, `name`, `version`, `status` (`active` or `retired`), `media_type`, the `description`, `input_schema`, `output_schema` and `warnings` its capability gives when it gives them, `created_at`, `created_by`, the caller who created it, by `id` and `name`, as its first version's fact, `definition_created`, holds it, `updated_at`, `retired_at` on a retired definition, its document as `source`, the `overview` its author gave with the version, when one was given (see [The overview of a definition](#the-overview-of-a-definition)), and `used_by`, the active definitions of the playbook that use it, when any does (see [Used by](#used-by)). `get_definition` adds `standing` when its capability's `standing` answers one (below). Of the `details` its capability gives, which the registry keeps, a definition answers only `details.shape`, the name of its [output shape](#output-shapes), when the details name one, and leaves the rest out (`src/operations/definition-views.ts`). A listed definition is the same without `source`, `overview`, `used_by` and `standing`. Times are ISO 8601 UTC strings read from Effect's `Clock`. `DefinitionSchema`, `ListedDefinitionSchema`, `RunSchema` and `RunDetailSchema` are the schemas. - `type` is the type a capability states. The published JSON Schema of the field is a plain `{ "type": "string", "enum": [...], "description": ... }` of the known types. Decoding checks the shape and then that a capability of the server serves the type, so a malformed type and one the server does not run are both `invalid_input` at `/type` on every operation that takes one, `list_runs` and `get_playbook_analytics` among them; the second names the types the server runs. Effect would publish the types under `allOf`, because it inlines no `enum` from a check, so each operation replaces that one property of its input's JSON Schema. - `name` is 3 to 48 lowercase letters, digits and hyphens, starting with a letter, `definitionNamePattern`, which a capability that names another definition in its document checks it by: unique among the definitions of its type in the playbook, and never reused. @@ -113,7 +113,7 @@ The queries need `playbook:read` and the commands `playbook:write`. `run_definit ## Runs -A run carries `run_id`, `type`, `name`, `definition_version`, `status`, `output` when it succeeded, `rejection` (`reason`, `detail`, `issues` for `invalid_input`, for `unavailable` and `conflict` the `kind` and `because` the capability gave, and for `cancelled` the kind of the cancel) when the capability rejected it or the run was cancelled, `started_at`, `started_by`, and `finished_at` once it ended. `get_run` also shows the `record` the capability gave of what it did: of the run that succeeded, of what it did before it rejected the run when its rejection carries a `record`, such as the tokens a model call spent, or of the work it started that finishes later, kept when that work ends rejected or failed and dropped when a retry starts the run again. `run_definition` answers the same run, its record included, as `get_run` does, so the run has one shape on the wire, `RunDetailSchema`, and the words of either read the record beside the output. Its status is `started` while it runs, while work it started finishes after the call returned, or when the process ended before it finished; then `succeeded`, `rejected` or `failed`. +A run carries `run_id`, `type`, `name`, `definition_version`, `status`, `output` when it succeeded, `rejection` (`reason`, `detail`, `issues` for `invalid_input`, for `unavailable` and `conflict` the `kind` and `because` the capability gave, and for `cancelled` the kind of the cancel) when the capability rejected it or the run was cancelled, `started_at`, `started_by`, the caller who started it, by `id` and `name`, and `finished_at` once it ended. `get_run` also shows the `record` the capability gave of what it did: of the run that succeeded, of what it did before it rejected the run when its rejection carries a `record`, such as the tokens a model call spent, or of the work it started that finishes later, kept when that work ends rejected or failed and dropped when a retry starts the run again. `run_definition` answers the same run, its record included, as `get_run` does, so the run has one shape on the wire, `RunDetailSchema`, and the words of either read the record beside the output. Its status is `started` while it runs, while work it started finishes after the call returned, or when the process ended before it finished; then `succeeded`, `rejected` or `failed`. `run_definition` answers with the run when it succeeded, and in status `started` when the capability started work that finishes later. When the capability rejects it with `invalid_input`, `unavailable` or `conflict`, the operation is rejected with that reason, detail and issues, and the rejection is recorded on the run. When the capability breaks down, the call fails with an incident, as any defect does, and the run is recorded as `failed`; the defect itself goes only to the incident reporter. A rejected operation carries no run id, so a caller that wants to read a rejected run later gives it an id. @@ -327,7 +327,7 @@ Each run is a stream of its own, named `runs/{run_id}` relative to the playbook. - `model_call_started`, and `model_call_answered` or `model_call_failed`, for each model call it makes, described under [Model calls](#model-calls) - `request_answered`, the one answer of a request the run waits on, and the facts its capability declares, each recorded through the `work` command, described under [The work of a run that finishes later](#the-work-of-a-run-that-finishes-later) -The context of each of the three endings, and of every later fact, carries the definition and the chain of the attempt, which the decider's `context` takes from the run's latest start in its state, so an ending says what ran without a read of the start; the state reads `started_at` and `started_by` from the start's context as it folds the recorded events, `{ type, data, context }`. +The context of each of the three endings, and of every later fact, carries the definition and the chain of the attempt, which the decider's `context` takes from the run's latest start in its state, so an ending says what ran without a read of the start; the state reads `started_at` from the start's context and `started_by` from its data, which records the caller who started it, by `id` and `name`, beside the context's `by`, the id alone, as it folds the recorded events, `{ type, data, context }`. ### Cancelling a run diff --git a/packages/definitions/measure/measure-loads.ts b/packages/definitions/measure/measure-loads.ts index b79e822ff..524c221d4 100644 --- a/packages/definitions/measure/measure-loads.ts +++ b/packages/definitions/measure/measure-loads.ts @@ -48,7 +48,13 @@ const widest = defineCapability({ const { createDefinition, updateDefinition, getDefinition, listDefinitions } = definitionOperationsFor([widest]); -const caller: CallerIdentity = { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }; +const caller: CallerIdentity = { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: allPermissions, + playbooks: '*', +}; const dispatcher = makeDispatcher([]); diff --git a/packages/definitions/src/analytics/get-playbook-analytics.test.ts b/packages/definitions/src/analytics/get-playbook-analytics.test.ts index 70336113e..51b1c7df9 100644 --- a/packages/definitions/src/analytics/get-playbook-analytics.test.ts +++ b/packages/definitions/src/analytics/get-playbook-analytics.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { defineGetPlaybookAnalytics } from '../index.ts'; import { RunEventSchema, type RecordedRunEvent, type RunEvent } from '../runs/run-events.ts'; import type { RunRejection } from '../runs/run.ts'; -import { acmeReader } from '../testing/callers.ts'; +import { acmeReader, recordedAdmin } from '../testing/callers.ts'; import { echo } from '../testing/capabilities/echo.ts'; import { asQueryString, harness, toPlaybook, type Harness } from '../testing/harness.ts'; @@ -44,7 +44,10 @@ function ofTheRun(name: string, type: string, at: string) { const usage = { input: { total: 1200, cache_read: 1000 }, output: { total: 300 } }; function started(name: string, at: string, type = 'reasoning'): Writing { - return { event: { type: 'run_started', data: { input: {} } }, context: ofTheRun(name, type, at) }; + return { + event: { type: 'run_started', data: { started_by: recordedAdmin, input: {} } }, + context: ofTheRun(name, type, at), + }; } function succeeded(at: string, record: Schema.JsonObject = {}, name = 'triage', type = 'reasoning'): Writing { diff --git a/packages/definitions/src/attempts/recorded-attempt.test.ts b/packages/definitions/src/attempts/recorded-attempt.test.ts index 2f73fdc00..8bacf37e4 100644 --- a/packages/definitions/src/attempts/recorded-attempt.test.ts +++ b/packages/definitions/src/attempts/recorded-attempt.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { modelCallAnswered, modelCallSent } from '../journal-testing/model-calls.ts'; import type { RecordedRunEvent } from '../runs/run-events.ts'; +import { recordedAdmin } from '../testing/callers.ts'; import { recordedOnTheRun, recordedWith, testRunId } from '../testing/run-facts.ts'; import { recordedAttempt } from './recorded-attempt.ts'; @@ -14,11 +15,11 @@ const context = { definitionVersion: 1, }; -const started = recordedWith(context)({ type: 'run_started', data: { input: {} } }); +const started = recordedWith(context)({ type: 'run_started', data: { started_by: recordedAdmin, input: {} } }); const startedAgain = recordedWith({ ...context, at: '2026-10-01T09:05:00.000Z' })({ type: 'run_started', - data: { input: {} }, + data: { started_by: recordedAdmin, input: {} }, }); const failed = recordedWith(context)({ type: 'run_failed', data: {} }); diff --git a/packages/definitions/src/cancellation/cancel-run.test.ts b/packages/definitions/src/cancellation/cancel-run.test.ts index 12d310306..fadbbad02 100644 --- a/packages/definitions/src/cancellation/cancel-run.test.ts +++ b/packages/definitions/src/cancellation/cancel-run.test.ts @@ -14,7 +14,7 @@ const asStarted = { definition_version: 1, status: 'started', started_at: firstMoment, - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, }; const everything = { kind: 'everything' } as const; diff --git a/packages/definitions/src/capability/capability.test.ts b/packages/definitions/src/capability/capability.test.ts index e21d587e8..f4b6585e0 100644 --- a/packages/definitions/src/capability/capability.test.ts +++ b/packages/definitions/src/capability/capability.test.ts @@ -36,7 +36,7 @@ const run: RunContext = { id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', org: 'acme', playbook: 'alpha', - caller: { id: 'acme-admin', org: 'acme', permissions: ['playbook:write'], playbooks: '*' }, + caller: { id: 'acme-admin', name: 'acme-admin', org: 'acme', permissions: ['playbook:write'], playbooks: '*' }, definition: { name: 'count', version: 3 }, journal: recordingJournal(), lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, diff --git a/packages/definitions/src/events/playbook-facts.test.ts b/packages/definitions/src/events/playbook-facts.test.ts index 1ee9f3419..f052304e9 100644 --- a/packages/definitions/src/events/playbook-facts.test.ts +++ b/packages/definitions/src/events/playbook-facts.test.ts @@ -11,6 +11,7 @@ import { type RunEvent, } from '../index.ts'; import { runStreamNameOf } from '../runs/run-decider.ts'; +import { recordedAdmin } from '../testing/callers.ts'; import { testRunId as runId } from '../testing/run-facts.ts'; const recordId = '0b1c2d3e-4f50-5a6b-8c7d-8e9fa0b1c2d3'; @@ -58,7 +59,7 @@ const aboutTheRun = { describe('the facts of a run as events', () => { it('are its start and its endings, about the run, its context as attributes and its fact as data', () => { const facts = [ - ofRun({ type: 'run_started', data: { input: { text: 'the quarter' } } }), + ofRun({ type: 'run_started', data: { started_by: recordedAdmin, input: { text: 'the quarter' } } }), ofRun({ type: 'run_rejected', data: { rejection: { reason: 'unavailable', detail: 'Busy' } } }), ofRun({ type: 'run_rejected', data: { rejection: { reason: 'cancelled', kind: 'requested', detail: 'No' } } }), ofRun({ type: 'run_failed', data: {} }), @@ -117,7 +118,7 @@ describe('the records of a run that are no facts', () => { ofRun({ type: 'run_deferred', data: { record: { run: 'r-1' } } }), ofRun({ type: 'run_cancel_requested', data: { kind: 'requested', reason: 'No' } }), ofRun({ type: 'tool_call_failed', data: { number: 1, because: 'timed_out', duration_ms: 1, jsonrpc_id: 1 } }), - ofRun({ type: 'request_answered', data: { answer: { choice: 'approve' }, reply } }), + ofRun({ type: 'request_answered', data: { answered_by: recordedAdmin, answer: { choice: 'approve' }, reply } }), { ...ofRun({ type: 'run_failed', data: {} }), type: 'nudge_sent', data: { number: 1 } }, ].map((record) => playbookFactOf(record)), ).toEqual([undefined, undefined, undefined, undefined, undefined]); @@ -132,7 +133,10 @@ describe('the facts of a definition as events', () => { it('are its creation, its changes and its retirement, about the definition and its version, with no subject and no data', () => { const named = { at, by: 'acme-admin', definitionType: 'reasoning', definitionName: 'summary' }; const facts = [ - ofDefinitions({ type: 'definition_created', data: { content } }, { ...named, definitionVersion: 1 }), + ofDefinitions( + { type: 'definition_created', data: { content, created_by: recordedAdmin } }, + { ...named, definitionVersion: 1 }, + ), ofDefinitions({ type: 'definition_updated', data: { content } }, { ...named, definitionVersion: 2 }), ofDefinitions({ type: 'definition_retired', data: {} }, { ...named, definitionVersion: 2 }), ofDefinitions({ type: 'definition_retired', data: {} }, named), @@ -159,8 +163,11 @@ describe('the records that are no facts of the playbook', () => { it('are the records it cannot read as what their stream holds, which it answers with none, never a failure', () => { const unreadable: readonly RecordedFact[] = [ { ...ofRun({ type: 'run_failed', data: {} }), data: { incident: 7 } }, - { ...ofDefinitions({ type: 'definition_created', data: { content } }, ofSummary), data: 'not an event' }, - { ...ofRun({ type: 'run_started', data: { input: {} } }), data: null }, + { + ...ofDefinitions({ type: 'definition_created', data: { content, created_by: recordedAdmin } }, ofSummary), + data: 'not an event', + }, + { ...ofRun({ type: 'run_started', data: { started_by: recordedAdmin, input: {} } }), data: null }, ]; expect(unreadable.map((record) => playbookFactOf(record))).toEqual([undefined, undefined, undefined]); diff --git a/packages/definitions/src/events/run-start-facts.test.ts b/packages/definitions/src/events/run-start-facts.test.ts index 740ccf86b..b137f3b5f 100644 --- a/packages/definitions/src/events/run-start-facts.test.ts +++ b/packages/definitions/src/events/run-start-facts.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { runStreamNameOf } from '../runs/run-decider.ts'; import type { RunEvent } from '../runs/run-events.ts'; +import { recordedAdmin } from '../testing/callers.ts'; import { testRunId as runId } from '../testing/run-facts.ts'; import { playbookFactOf } from './playbook-facts.ts'; @@ -43,7 +44,7 @@ describe('the facts of a run that answers a call of another run', () => { const called = { ...ofCheck, calledBy, callDepth: 1 }; expect([ - attributesOf({ type: 'run_started', data: { input: {} } }, called), + attributesOf({ type: 'run_started', data: { started_by: recordedAdmin, input: {} } }, called), attributesOf( { type: 'run_rejected', @@ -64,7 +65,7 @@ describe('the facts of a run a trigger started', () => { expect( [ - playbookFactOf(ofRun({ type: 'run_started', data: { input: {} } }, triggered)), + playbookFactOf(ofRun({ type: 'run_started', data: { started_by: recordedAdmin, input: {} } }, triggered)), playbookFactOf(ofRun({ type: 'run_failed', data: {} }, triggered)), ].map((fact) => [fact?.['triggerkind'], fact?.['triggerreference']]), ).toEqual([ diff --git a/packages/definitions/src/journal-testing/run-links.ts b/packages/definitions/src/journal-testing/run-links.ts index 9a2c57103..4596ee0ba 100644 --- a/packages/definitions/src/journal-testing/run-links.ts +++ b/packages/definitions/src/journal-testing/run-links.ts @@ -52,6 +52,7 @@ export function journalled(definitions: Harness, use: (journal: LedgerJournal definition_type: 'tool-user', name: 'caller', input: {}, + started_by: { id: 'acme-admin', name: 'acme-admin' }, definition_version: 1, calls_tools: true, runId: testRunId, diff --git a/packages/definitions/src/journal/run-journal.test.ts b/packages/definitions/src/journal/run-journal.test.ts index 1e7fcf0e1..06df62839 100644 --- a/packages/definitions/src/journal/run-journal.test.ts +++ b/packages/definitions/src/journal/run-journal.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { RunCommand } from '../runs/run-commands.ts'; import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; -import { acmeAdmin } from '../testing/callers.ts'; +import { acmeAdmin, recordedAdmin } from '../testing/callers.ts'; import { startOfCall, toolUser } from '../testing/capabilities/tool-user.ts'; import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { harness, toPlaybook } from '../testing/harness.ts'; @@ -149,6 +149,7 @@ describe('a run that called tools', () => { await recordedDirectly( { type: 'start', + started_by: recordedAdmin, definition_type: 'tool-user', name: 'caller', input: {}, diff --git a/packages/definitions/src/operations/claims-of-another-type.test.ts b/packages/definitions/src/operations/claims-of-another-type.test.ts index 5a450c0fc..6895159c3 100644 --- a/packages/definitions/src/operations/claims-of-another-type.test.ts +++ b/packages/definitions/src/operations/claims-of-another-type.test.ts @@ -2,7 +2,7 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { runDecider } from '../runs/run-decider.ts'; -import { acmeAdmin } from '../testing/callers.ts'; +import { acmeAdmin, recordedAdmin } from '../testing/callers.ts'; import { echo } from '../testing/capabilities/echo.ts'; import { quietMs, resumer } from '../testing/capabilities/resumer.ts'; import { definitionOperationsFor } from '../testing/definition-operations.ts'; @@ -14,6 +14,7 @@ const toAlpha = toPlaybook('acme', 'alpha'); const startOfAnEcho = { type: 'start', + started_by: recordedAdmin, definition_type: 'echo', name: 'step', input: {}, diff --git a/packages/definitions/src/operations/create-definition.test.ts b/packages/definitions/src/operations/create-definition.test.ts index 608daa07e..2cb0461cb 100644 --- a/packages/definitions/src/operations/create-definition.test.ts +++ b/packages/definitions/src/operations/create-definition.test.ts @@ -1,13 +1,13 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { acmeAdmin } from '../testing/callers.ts'; +import { acmeAdmin, adaOfAcme } from '../testing/callers.ts'; import { echo } from '../testing/capabilities/echo.ts'; import { probe } from '../testing/capabilities/probe.ts'; import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { firstMoment, harness, toPlaybook } from '../testing/harness.ts'; -const { createDefinition, retireDefinition } = definitionOperationsFor([echo, probe().capability]); +const { createDefinition, retireDefinition, updateDefinition } = definitionOperationsFor([echo, probe().capability]); const toAlpha = toPlaybook('acme', 'alpha'); @@ -60,7 +60,7 @@ describe('create_definition', () => { required: ['greeting', 'input'], }, created_at: firstMoment, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: firstMoment, source: hello, overview: greetOverview, @@ -69,6 +69,26 @@ describe('create_definition', () => { }); }); +describe('who created a definition', () => { + it('is the caller who created it, a person by their id and their name', async () => { + const created = await harness().call( + createDefinition, + toAlpha(adaOfAcme, { type: 'probe', name: 'plain', source: 'text' }), + ); + + expect(created).toMatchObject({ output: { created_by: { id: 'person:user_2ada', name: 'Ada Lovelace' } } }); + }); + + it('stays the caller who created it when another updates it', async () => { + const definitions = harness(); + await definitions.call(createDefinition, toAlpha(adaOfAcme, { type: 'probe', name: 'plain', source: 'text' })); + + expect( + await definitions.call(updateDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'more' })), + ).toMatchObject({ output: { version: 2, created_by: { id: 'person:user_2ada', name: 'Ada Lovelace' } } }); + }); +}); + describe('the warnings of a definition', () => { it('are what its capability found in the document, shown with the definition', async () => { const source = '{"greeting": "Hello", "warnings": ["Line 2: greeting may be too warm for some readers"]}'; @@ -97,7 +117,7 @@ describe('the definition create_definition records', () => { status: 'active', media_type: 'text/plain', created_at: firstMoment, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: firstMoment, source: 'just text', }, diff --git a/packages/definitions/src/operations/create-definition.ts b/packages/definitions/src/operations/create-definition.ts index c726ad489..752fe51f1 100644 --- a/packages/definitions/src/operations/create-definition.ts +++ b/packages/definitions/src/operations/create-definition.ts @@ -1,4 +1,4 @@ -import { defineCommand } from '@on.auto/operations'; +import { Caller, defineCommand, recordedCallerOf } from '@on.auto/operations'; import { Effect, Schema } from 'effect'; import type { Capability } from '../capability/capability.ts'; @@ -40,7 +40,12 @@ export function defineCreateDefinition(capabilities: readonly Capability[]) { return yield* answeredDefinition( capability, known.types, - yield* recordInRegistry(capability, { type: 'create', name, content }), + yield* recordInRegistry(capability, { + type: 'create', + name, + content, + created_by: recordedCallerOf(yield* Caller), + }), ); }), plainLanguage: { diff --git a/packages/definitions/src/operations/deferred-run.test.ts b/packages/definitions/src/operations/deferred-run.test.ts index b056f0309..54cb50b31 100644 --- a/packages/definitions/src/operations/deferred-run.test.ts +++ b/packages/definitions/src/operations/deferred-run.test.ts @@ -12,7 +12,7 @@ const waiting = { definition_version: 1, status: 'started', started_at: firstMoment, - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, record: { handed_on: relayedId }, }, }; diff --git a/packages/definitions/src/operations/get-definition.test.ts b/packages/definitions/src/operations/get-definition.test.ts index 4a79459f4..7024015a6 100644 --- a/packages/definitions/src/operations/get-definition.test.ts +++ b/packages/definitions/src/operations/get-definition.test.ts @@ -50,7 +50,7 @@ describe('get_definition', () => { status: 'active', media_type: 'text/plain', created_at: firstMoment, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: firstMoment, source: 'text', overview: plainOverview, diff --git a/packages/definitions/src/operations/isolation.test.ts b/packages/definitions/src/operations/isolation.test.ts index acbb290f8..1141c20da 100644 --- a/packages/definitions/src/operations/isolation.test.ts +++ b/packages/definitions/src/operations/isolation.test.ts @@ -47,7 +47,7 @@ describe('the definitions and runs of a playbook', () => { await call(runDefinition, toAlpha(acmeAdmin, { ...greet, run_id: runId })); expect(await call(runDefinition, toGamma(globexAdmin, { ...greet, run_id: runId }))).toMatchObject({ - output: { output: { greeting: 'Hola' }, started_by: 'globex-admin' }, + output: { output: { greeting: 'Hola' }, started_by: { id: 'globex-admin', name: 'globex-admin' } }, }); }); @@ -91,7 +91,7 @@ describe('a caller limited to some playbooks', () => { const denied = { status: 'rejected', reason: 'forbidden', detail: 'The caller may not access this playbook' }; expect(await call(createDefinition, toAlpha(acmeAlphaKeeper, hello))).toMatchObject({ - output: { created_by: 'acme-alpha-keeper' }, + output: { created_by: { id: 'acme-alpha-keeper', name: 'acme-alpha-keeper' } }, }); expect(await call(runDefinition, toAlpha(acmeAlphaKeeper, greet))).toMatchObject({ status: 'succeeded' }); expect(await call(createDefinition, toBeta(acmeAlphaKeeper, hello))).toEqual(denied); diff --git a/packages/definitions/src/operations/list-definitions.test.ts b/packages/definitions/src/operations/list-definitions.test.ts index 7a1409828..127629afb 100644 --- a/packages/definitions/src/operations/list-definitions.test.ts +++ b/packages/definitions/src/operations/list-definitions.test.ts @@ -29,7 +29,7 @@ function listed(name: string) { status: 'active', media_type: 'text/plain', created_at: firstMoment, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: firstMoment, }; } diff --git a/packages/definitions/src/operations/retire-definition.test.ts b/packages/definitions/src/operations/retire-definition.test.ts index 71398fa98..73d36ce6c 100644 --- a/packages/definitions/src/operations/retire-definition.test.ts +++ b/packages/definitions/src/operations/retire-definition.test.ts @@ -24,7 +24,7 @@ const retiredPlain = { status: 'retired', media_type: 'text/plain', created_at: firstMoment, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: later, retired_at: later, source: 'text', diff --git a/packages/definitions/src/operations/run-definition.test.ts b/packages/definitions/src/operations/run-definition.test.ts index 44688371c..3698570a6 100644 --- a/packages/definitions/src/operations/run-definition.test.ts +++ b/packages/definitions/src/operations/run-definition.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { acmeAdmin } from '../testing/callers.ts'; +import { acmeAdmin, adaOfAcme } from '../testing/callers.ts'; import { echo } from '../testing/capabilities/echo.ts'; import { probe } from '../testing/capabilities/probe.ts'; import { definitionOperationsFor } from '../testing/definition-operations.ts'; @@ -27,7 +27,7 @@ const greeted = { status: 'succeeded', output: { greeting: 'Hello', input: {} }, started_at: firstMoment, - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: firstMoment, }; @@ -77,7 +77,7 @@ describe('run_definition', () => { status: 'succeeded', output: { greeting: 'Hello', input: { who: 'Ada' } }, started_at: later, - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: later, record: { greeting: 'Hello' }, }, @@ -93,6 +93,17 @@ describe('run_definition', () => { }); }); +describe('who started a run', () => { + it('is the caller who started it, a person by their id and their name, as get_run reads it', async () => { + const { call } = await withGreetAndPlain(); + await call(runDefinition, toAlpha(adaOfAcme, { type: 'echo', name: 'greet', run_id: runId })); + + expect(await call(getRun, toAlpha(acmeAdmin, { run_id: runId }))).toMatchObject({ + output: { started_by: { id: 'person:user_2ada', name: 'Ada Lovelace' } }, + }); + }); +}); + describe('the run that run_definition runs', () => { it('gives the capability an empty object when the input is left out', async () => { const { call } = await withGreetAndPlain(); @@ -163,7 +174,7 @@ describe('run_definition rejected by the capability', () => { issues: [{ detail: 'Expected anything but reject', pointer: '/input/reject' }], }, started_at: firstMoment, - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: firstMoment, }, }); diff --git a/packages/definitions/src/operations/run-failures.test.ts b/packages/definitions/src/operations/run-failures.test.ts index 2b2a4ee06..0ea5ea10f 100644 --- a/packages/definitions/src/operations/run-failures.test.ts +++ b/packages/definitions/src/operations/run-failures.test.ts @@ -35,7 +35,7 @@ const failedRun = { definition_version: 1, status: 'failed', started_at: firstMoment, - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: firstMoment, }; diff --git a/packages/definitions/src/operations/run-requests.ts b/packages/definitions/src/operations/run-requests.ts index 0afd4df06..e2f8006eb 100644 --- a/packages/definitions/src/operations/run-requests.ts +++ b/packages/definitions/src/operations/run-requests.ts @@ -1,4 +1,11 @@ -import { CallLineage, Conflict, type GivenLineage } from '@on.auto/operations'; +import { + CallLineage, + Caller, + Conflict, + recordedCallerOf, + type GivenLineage, + type RecordedCaller, +} from '@on.auto/operations'; import { Effect } from 'effect'; import type { Capability } from '../capability/capability.ts'; @@ -18,12 +25,18 @@ function tooDeep(callDepth: number): Conflict { }); } -function startOf(request: RunRequest, { definition, prepared, createOnly }: Prepared, given: GivenLineage) { +function startOf( + request: RunRequest, + { definition, prepared, createOnly }: Prepared, + given: GivenLineage, + startedBy: RecordedCaller, +) { const { depth, callDepth, calledBy, trigger } = given; const shape = outputShapeOf(prepared, request.input); return { type: 'start' as const, ...request, + started_by: startedBy, definition_version: definition.version, calls_tools: prepared.callsTools, finishes_later: prepared.finishesLaterFor(request.input), @@ -50,7 +63,8 @@ const runPrepared = Effect.fnUntraced(function* ( const correlationId = given.lineage?.correlationId ?? id; return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - const { messageId, startedAt, modelCallsBefore } = yield* recordRun(id, startOf(request, run, given), { + const startedBy = recordedCallerOf(yield* Caller); + const { messageId, startedAt, modelCallsBefore } = yield* recordRun(id, startOf(request, run, given, startedBy), { causationId: given.lineage?.causationId ?? null, correlationId, }); diff --git a/packages/definitions/src/operations/start-version.test.ts b/packages/definitions/src/operations/start-version.test.ts index cb5cdf85c..0e0b5ba07 100644 --- a/packages/definitions/src/operations/start-version.test.ts +++ b/packages/definitions/src/operations/start-version.test.ts @@ -53,7 +53,7 @@ describe('a start of one version of a definition, once', () => { status: 'succeeded', output: { greeting: 'Hello', input: { name: 'Ada' } }, started_at: firstMoment, - started_by: 'playbook:alpha', + started_by: { id: 'playbook:alpha', name: 'alpha' }, finished_at: firstMoment, record: { greeting: 'Hello' }, }, @@ -80,7 +80,10 @@ describe('a start of a version that a trigger asked for', () => { ), ); - expect(started).toMatchObject({ status: 'succeeded', output: { started_by: 'playbook:alpha' } }); + expect(started).toMatchObject({ + status: 'succeeded', + output: { started_by: { id: 'playbook:alpha', name: 'alpha' } }, + }); expect(records).toMatchObject([ { type: 'run_started', context: { trigger } }, { type: 'run_succeeded', context: { trigger } }, diff --git a/packages/definitions/src/operations/update-definition.test.ts b/packages/definitions/src/operations/update-definition.test.ts index d60978a36..ad04bff42 100644 --- a/packages/definitions/src/operations/update-definition.test.ts +++ b/packages/definitions/src/operations/update-definition.test.ts @@ -69,7 +69,7 @@ describe('update_definition', () => { required: ['greeting', 'input'], }, created_at: firstMoment, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: later, source: '{"greeting": "Howdy"}', overview: greetOverview, diff --git a/packages/definitions/src/plain-language/definition-words.test.ts b/packages/definitions/src/plain-language/definition-words.test.ts index 4ca25ca81..f03df4a59 100644 --- a/packages/definitions/src/plain-language/definition-words.test.ts +++ b/packages/definitions/src/plain-language/definition-words.test.ts @@ -32,7 +32,7 @@ const greet = { source: '{"greeting":"Hello"}', overview: 'Greets each guest who arrives at the front desk.', created_at: '2026-10-02T09:00:00.000Z', - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: '2026-10-02T09:00:00.000Z', }; diff --git a/packages/definitions/src/plain-language/reading-words.test.ts b/packages/definitions/src/plain-language/reading-words.test.ts index bf297c38f..0ae7f8f0e 100644 --- a/packages/definitions/src/plain-language/reading-words.test.ts +++ b/packages/definitions/src/plain-language/reading-words.test.ts @@ -15,7 +15,7 @@ function run(status: string): Readonly> { definition_version: 1, status, started_at: '2026-10-01T09:00:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, }; } diff --git a/packages/definitions/src/plain-language/run-words.test.ts b/packages/definitions/src/plain-language/run-words.test.ts index d87094a8c..bbf034886 100644 --- a/packages/definitions/src/plain-language/run-words.test.ts +++ b/packages/definitions/src/plain-language/run-words.test.ts @@ -28,7 +28,7 @@ const run = { status: 'succeeded', output: { greeting: 'Hello' }, started_at: '2026-10-02T09:00:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: '2026-10-02T09:00:01.000Z', }; diff --git a/packages/definitions/src/presenting/definition-presenter.test.ts b/packages/definitions/src/presenting/definition-presenter.test.ts index 1c7a9cf52..9db6ee155 100644 --- a/packages/definitions/src/presenting/definition-presenter.test.ts +++ b/packages/definitions/src/presenting/definition-presenter.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { makeDefinitionPresenters } from '../index.ts'; import type { DefinitionEvent } from '../registry/definition-events.ts'; +import { recordedAdmin } from '../testing/callers.ts'; import { echo } from '../testing/capabilities/echo.ts'; const { present } = presentationOf(makeDefinitionPresenters([echo])); @@ -48,7 +49,7 @@ describe('the presenter of the definitions of a capability', () => { warnings: ['one', 'two'], }; - expect(presented({ type: 'definition_created', data: { content } }, 1)).toMatchObject({ + expect(presented({ type: 'definition_created', data: { content, created_by: recordedAdmin } }, 1)).toMatchObject({ type: 'definition_created', summary: 'The greeting “greet” was created.', data: { @@ -89,7 +90,7 @@ describe('the presenter of the changes to a definition', () => { presented( { type: 'definition_created', - data: { content: { source: 'Hi', description } }, + data: { content: { source: 'Hi', description }, created_by: recordedAdmin }, }, 1, ), diff --git a/packages/definitions/src/presenting/presenter-catalog.test.ts b/packages/definitions/src/presenting/presenter-catalog.test.ts index fb9b0a5b0..6dd5c29b0 100644 --- a/packages/definitions/src/presenting/presenter-catalog.test.ts +++ b/packages/definitions/src/presenting/presenter-catalog.test.ts @@ -15,6 +15,7 @@ import { makeDefinitionPresenters } from '../index.ts'; import { definitionsDecider, definitionTypeStreamOf } from '../registry/definitions-decider.ts'; import { mostInputBytes, mostResultBytes } from '../runs/recorded-size.ts'; import { coreRunFactTypes, type RunEvent } from '../runs/run-events.ts'; +import { recordedAdmin } from '../testing/callers.ts'; import { asking, echo } from '../testing/index.ts'; const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; @@ -95,7 +96,7 @@ const largestAnswer = { const largestReply = { id: awkward, sender: awkward }; const largestRunEvents: readonly RunEvent[] = [ - { type: 'run_started', data: { input: { text: 'x'.repeat(mostInputBytes - 16) } } }, + { type: 'run_started', data: { started_by: recordedAdmin, input: { text: 'x'.repeat(mostInputBytes - 16) } } }, { type: 'run_succeeded', data: { output: largestJson, record: largestJson } }, { type: 'run_rejected', @@ -130,7 +131,7 @@ const largestRunEvents: readonly RunEvent[] = [ }, { type: 'request_answered', - data: { answer: largestJson, claimed_for: awkward, reply: largestReply }, + data: { answer: largestJson, claimed_for: awkward, reply: largestReply, answered_by: recordedAdmin }, }, ]; @@ -144,7 +145,7 @@ const largestContent = { }; const largestDefinitionEvents: readonly DefinitionEvent[] = [ - { type: 'definition_created', data: { content: largestContent } }, + { type: 'definition_created', data: { content: largestContent, created_by: recordedAdmin } }, { type: 'definition_updated', data: { content: largestContent } }, { type: 'definition_retired', data: {} }, ]; diff --git a/packages/definitions/src/presenting/run-presenter.test.ts b/packages/definitions/src/presenting/run-presenter.test.ts index 98c12e915..b5b18bde9 100644 --- a/packages/definitions/src/presenting/run-presenter.test.ts +++ b/packages/definitions/src/presenting/run-presenter.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { makeDefinitionPresenters } from '../index.ts'; import type { RunEvent } from '../runs/run-events.ts'; import { askedRequest, asking } from '../testing/asking.ts'; +import { recordedAdmin } from '../testing/callers.ts'; import { echo } from '../testing/capabilities/echo.ts'; import { testRunId } from '../testing/run-facts.ts'; @@ -50,12 +51,12 @@ function presented(event: RunEvent, context: Context = ofGreet) { } describe('the presenter of the events of a run', () => { - it('presents a start with its input as its data and the definition that ran, who and when as its metadata', () => { - expect(presented({ type: 'run_started', data: { input: { who: 'Ada' } } })).toEqual({ + it('presents a start with its input and who started it as its data, and the definition that ran, who and when as its metadata', () => { + expect(presented({ type: 'run_started', data: { started_by: recordedAdmin, input: { who: 'Ada' } } })).toEqual({ id: '0b1c2d3e-4f50-5a6b-8c7d-8e9fa0b1c2d3', type: 'run_started', summary: 'A run of the greeting “greet” started.', - data: { input: { who: 'Ada' } }, + data: { started_by: recordedAdmin, input: { who: 'Ada' } }, metadata: { stream: `playbook/acme/alpha/runs/${testRunId}`, position: 1, @@ -71,7 +72,7 @@ describe('the presenter of the events of a run', () => { }); it('presents an input past 2 KiB as its size', () => { - const started = presented({ type: 'run_started', data: { input: 'x'.repeat(3000) } }); + const started = presented({ type: 'run_started', data: { started_by: recordedAdmin, input: 'x'.repeat(3000) } }); expect(started).toMatchObject({ data: { input_bytes: 3002 } }); }); @@ -79,9 +80,9 @@ describe('the presenter of the events of a run', () => { it('fails on a fact of a run recorded without its definition, which a presenter never hides', () => { const { by, at } = ofGreet; - expect(() => presented({ type: 'run_started', data: { input: {} } }, { runId: testRunId, by, at })).toThrow( - 'definitionType', - ); + expect(() => + presented({ type: 'run_started', data: { started_by: recordedAdmin, input: {} } }, { runId: testRunId, by, at }), + ).toThrow('definitionType'); }); it('fails on a record that is not a fact of a run, which the ledger never holds', () => { @@ -122,18 +123,24 @@ describe('the presenter of work that finishes later', () => { }); describe('the presenter of the answer a run took and of the facts its capability declares', () => { - it('presents the answer a run took, with whom the caller answered for or the reply that brought it', () => { + it('presents the answer a run took, with who answered it, whom they answered for or the reply that brought it', () => { const reply = { id: '1699.2', sender: 'ada' }; expect([ - presented({ type: 'request_answered', data: { answer: { choice: 'approve' } } }), - presented({ type: 'request_answered', data: { answer: { choice: 'approve' }, claimed_for: 'ada' } }), - presented({ type: 'request_answered', data: { answer: { choice: 'reject' }, reply } }), + presented({ type: 'request_answered', data: { answered_by: recordedAdmin, answer: { choice: 'approve' } } }), + presented({ + type: 'request_answered', + data: { answered_by: recordedAdmin, answer: { choice: 'approve' }, claimed_for: 'ada' }, + }), + presented({ + type: 'request_answered', + data: { answered_by: recordedAdmin, answer: { choice: 'reject' }, reply }, + }), ]).toMatchObject([ { type: 'request_answered', summary: 'The request was answered, and the run takes the answer.', - data: { answer: { choice: 'approve' } }, + data: { answer: { choice: 'approve' }, answered_by: recordedAdmin }, }, { data: { answer: { choice: 'approve' }, claimed_for: 'ada' } }, { diff --git a/packages/definitions/src/presenting/run-presenter.ts b/packages/definitions/src/presenting/run-presenter.ts index 4566ba124..78f479010 100644 --- a/packages/definitions/src/presenting/run-presenter.ts +++ b/packages/definitions/src/presenting/run-presenter.ts @@ -97,7 +97,7 @@ function resumedFact({ type, data }: Shown<'attempt_resumed'>): PresentedFact { } function answerFact({ type, data }: Shown<'request_answered'>): PresentedFact { - const { answer, claimed_for: claimedFor, reply } = data; + const { answer, claimed_for: claimedFor, reply, answered_by: answeredBy } = data; return { type, summary: requestAnswered(reply !== undefined), @@ -105,6 +105,7 @@ function answerFact({ type, data }: Shown<'request_answered'>): PresentedFact { answer, ...(claimedFor === undefined ? {} : { claimed_for: named(claimedFor) }), ...(reply === undefined ? {} : { reply: { id: named(reply.id), sender: named(reply.sender) } }), + answered_by: answeredBy, }, }; } diff --git a/packages/definitions/src/presenting/run-starts-presenter.test.ts b/packages/definitions/src/presenting/run-starts-presenter.test.ts index 3e318c446..fd7183628 100644 --- a/packages/definitions/src/presenting/run-starts-presenter.test.ts +++ b/packages/definitions/src/presenting/run-starts-presenter.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { makeDefinitionPresenters } from '../index.ts'; import type { RunEvent } from '../runs/run-events.ts'; +import { recordedAdmin } from '../testing/callers.ts'; import { echo } from '../testing/capabilities/echo.ts'; import { testRunId as runId } from '../testing/run-facts.ts'; @@ -44,7 +45,10 @@ describe('the presenter of a run that answers a call of another run', () => { const calledBy = { runId: '0199a3c4-7d2e-7c1a-9b3f-000000000001', reference: '/do/0/ask', run: 2 }; expect( - presented({ type: 'run_started', data: { input: {} } }, { ...ofGreet, callDepth: 1, calledBy }), + presented( + { type: 'run_started', data: { started_by: recordedAdmin, input: {} } }, + { ...ofGreet, callDepth: 1, calledBy }, + ), ).toMatchObject({ metadata: { call_depth: 1, @@ -91,7 +95,10 @@ describe('the presenter of a cancel request', () => { }); function startedBy(kind: 'event' | 'cron' | 'every', reference: string) { - return presented({ type: 'run_started', data: { input: [] } }, { ...ofGreet, trigger: { kind, reference } }); + return presented( + { type: 'run_started', data: { started_by: recordedAdmin, input: [] } }, + { ...ofGreet, trigger: { kind, reference } }, + ); } describe('the presenter of the start of a run a trigger started', () => { diff --git a/packages/definitions/src/reading/list-runs.test.ts b/packages/definitions/src/reading/list-runs.test.ts index c2fc3f303..38d67c203 100644 --- a/packages/definitions/src/reading/list-runs.test.ts +++ b/packages/definitions/src/reading/list-runs.test.ts @@ -77,7 +77,7 @@ const everyEndingNewestFirst = [ definition_version: 1, status: 'succeeded', started_at: '2026-10-01T09:06:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: '2026-10-01T09:06:00.000Z', }, { @@ -87,7 +87,7 @@ const everyEndingNewestFirst = [ definition_version: 1, status: 'failed', started_at: '2026-10-01T09:05:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: '2026-10-01T09:05:00.000Z', }, { @@ -97,7 +97,7 @@ const everyEndingNewestFirst = [ definition_version: 1, status: 'started', started_at: '2026-10-01T09:04:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, }, { run_id: startedTwice, @@ -106,7 +106,7 @@ const everyEndingNewestFirst = [ definition_version: 1, status: 'succeeded', started_at: '2026-10-01T09:03:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: '2026-10-01T09:07:00.000Z', }, { @@ -117,7 +117,7 @@ const everyEndingNewestFirst = [ status: 'rejected', rejection: { reason: 'invalid_input' }, started_at: '2026-10-01T09:02:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: '2026-10-01T09:02:00.000Z', }, { @@ -127,7 +127,7 @@ const everyEndingNewestFirst = [ definition_version: 1, status: 'succeeded', started_at: '2026-10-01T09:01:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: '2026-10-01T09:01:00.000Z', }, ]; diff --git a/packages/definitions/src/reading/paging.test.ts b/packages/definitions/src/reading/paging.test.ts index 00a468dc0..d41dd1649 100644 --- a/packages/definitions/src/reading/paging.test.ts +++ b/packages/definitions/src/reading/paging.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { mostInputBytes } from '../runs/recorded-size.ts'; import { runDecider } from '../runs/run-decider.ts'; -import { acmeAdmin, globexAdmin } from '../testing/callers.ts'; +import { acmeAdmin, globexAdmin, recordedAdmin } from '../testing/callers.ts'; import { echo } from '../testing/capabilities/echo.ts'; import { relay } from '../testing/capabilities/relay.ts'; import { definitionOperationsFor } from '../testing/definition-operations.ts'; @@ -115,6 +115,7 @@ describe('the bounds of a page of runs', () => { const starting = Array.from({ length: mostExaminedInAPage + 1 }, (_, index) => ledger.service.execute(`playbook/acme/alpha/runs/${idOf(index)}`, runDecider, { type: 'start', + started_by: recordedAdmin, definition_type: 'echo', name: 'greet', definition_version: 1, diff --git a/packages/definitions/src/reading/run-status.test.ts b/packages/definitions/src/reading/run-status.test.ts index 88a93c466..dc12be25d 100644 --- a/packages/definitions/src/reading/run-status.test.ts +++ b/packages/definitions/src/reading/run-status.test.ts @@ -5,6 +5,7 @@ import { declaredFactsOf } from '../run-work/declared-facts.ts'; import type { RunEvent } from '../runs/run-events.ts'; import { evolveRun, startedRunOf } from '../runs/run-state.ts'; import { asking } from '../testing/asking.ts'; +import { recordedAdmin } from '../testing/callers.ts'; import { recordedWith, testRunId } from '../testing/run-facts.ts'; import { storedTypesByStatus } from './run-status.ts'; @@ -17,7 +18,7 @@ const recorded = recordedWith({ definitionVersion: 1, }); -const start: RunEvent = { type: 'run_started', data: { input: {} } }; +const start: RunEvent = { type: 'run_started', data: { started_by: recordedAdmin, input: {} } }; const ofTheTool = { server: 'chat', tool: 'post_message' }; @@ -57,7 +58,7 @@ const latestOfEveryType: Readonly> = { model_call_started: { type: 'model_call_started', data: { number: 1, ...modelCallSent() } }, model_call_answered: { type: 'model_call_answered', data: { number: 1, ...modelCallAnswered() } }, model_call_failed: { type: 'model_call_failed', data: { number: 1, ...modelCallTimedOut } }, - request_answered: { type: 'request_answered', data: { answer: { choice: 'approve' } } }, + request_answered: { type: 'request_answered', data: { answered_by: recordedAdmin, answer: { choice: 'approve' } } }, nudge_sent: { type: 'work', data: { fact: { type: 'nudge_sent', data: { number: 1 } } } }, }; diff --git a/packages/definitions/src/registry/definition-changes.test.ts b/packages/definitions/src/registry/definition-changes.test.ts index 18284d4dc..0addefbbb 100644 --- a/packages/definitions/src/registry/definition-changes.test.ts +++ b/packages/definitions/src/registry/definition-changes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { recordedAdmin } from '../testing/callers.ts'; import { definitionChangeOf } from './definition-changes.ts'; import type { DefinitionContent, DefinitionEvent } from './definition-events.ts'; @@ -34,10 +35,10 @@ function unversioned(event: DefinitionEvent) { describe('the change a record of a definition makes to what starts on its own', () => { it('activates a version with triggers, and deactivates the definition at a version without or at its retirement', () => { expect([ - changeOf({ type: 'definition_created', data: { content: reacting } }), + changeOf({ type: 'definition_created', data: { content: reacting, created_by: recordedAdmin } }), changeOf({ type: 'definition_updated', data: { content: plain } }, 'close', 2), changeOf({ type: 'definition_retired', data: {} }, 'close', 2), - changeOf({ type: 'definition_created', data: { content: plain } }, 'plain'), + changeOf({ type: 'definition_created', data: { content: plain, created_by: recordedAdmin } }, 'plain'), ]).toEqual([ { kind: 'activated', name: 'close', version: 1, triggers, at: at.at }, { kind: 'deactivated', name: 'close' }, @@ -49,7 +50,7 @@ describe('the change a record of a definition makes to what starts on its own', it('is unreadable for a record without its data, or without the version it is about', () => { expect([ definitionChangeOf({ type: 'definition_created', context: at }), - unversioned({ type: 'definition_created', data: { content: reacting } }), + unversioned({ type: 'definition_created', data: { content: reacting, created_by: recordedAdmin } }), unversioned({ type: 'definition_retired', data: {} }), ]).toEqual([{ kind: 'unreadable' }, { kind: 'unreadable' }, { kind: 'unreadable' }]); }); @@ -77,7 +78,7 @@ describe('the triggers a version activates', () => { stripped: { expressions: { ' $data.total as number > 1 ': ' $data.total > 1 ' } }, }; - expect(changeOf({ type: 'definition_created', data: { content } })).toMatchObject({ + expect(changeOf({ type: 'definition_created', data: { content, created_by: recordedAdmin } })).toMatchObject({ kind: 'activated', triggers: [ { kind: 'event', filters: [{ attributes: { type: 'noted', data: '${ $data.total > 1 }' } }] }, diff --git a/packages/definitions/src/registry/definition-commands.ts b/packages/definitions/src/registry/definition-commands.ts index 069debc68..ebcf79acd 100644 --- a/packages/definitions/src/registry/definition-commands.ts +++ b/packages/definitions/src/registry/definition-commands.ts @@ -1,9 +1,12 @@ +import type { RecordedCaller } from '@on.auto/operations'; + import type { DefinitionContent } from './definition-events.ts'; export interface DefinitionCreation { readonly type: 'create'; readonly name: string; readonly content: DefinitionContent; + readonly created_by: RecordedCaller; } export interface DefinitionUpdate { diff --git a/packages/definitions/src/registry/definition-events.ts b/packages/definitions/src/registry/definition-events.ts index aadb8ef55..4b47ccd29 100644 --- a/packages/definitions/src/registry/definition-events.ts +++ b/packages/definitions/src/registry/definition-events.ts @@ -1,4 +1,4 @@ -import { DefinitionContextSchema, factOf, type Decided } from '@on.auto/operations'; +import { DefinitionContextSchema, RecordedCallerSchema, factOf, type Decided } from '@on.auto/operations'; import { Schema } from 'effect'; import { StrippedFormsSchema } from '../capability/stripped-forms.ts'; @@ -21,7 +21,11 @@ export type DefinitionContent = typeof DefinitionContentSchema.Type; const SavedSchema = Schema.Struct({ content: DefinitionContentSchema }); export const DefinitionEventSchema = Schema.Union([ - factOf('definition_created', SavedSchema, DefinitionContextSchema), + factOf( + 'definition_created', + Schema.Struct({ ...SavedSchema.fields, created_by: RecordedCallerSchema }), + DefinitionContextSchema, + ), factOf('definition_updated', SavedSchema, DefinitionContextSchema), factOf('definition_retired', Schema.Struct({}), DefinitionContextSchema), ]); diff --git a/packages/definitions/src/registry/definition-registry.ts b/packages/definitions/src/registry/definition-registry.ts index d13d87585..1b576e152 100644 --- a/packages/definitions/src/registry/definition-registry.ts +++ b/packages/definitions/src/registry/definition-registry.ts @@ -8,8 +8,9 @@ export type DefinitionRegistry = ReadonlyMap; export const initialRegistry: DefinitionRegistry = new Map(); function createdDefinition({ data, context }: DefinitionCreated): StoredDefinition { - const { at, by, definitionName: name, definitionVersion: version } = context; - return { name, version, status: 'active', ...data.content, created_at: at, created_by: by, updated_at: at }; + const { at, definitionName: name, definitionVersion: version } = context; + const { content, created_by } = data; + return { name, version, status: 'active', ...content, created_at: at, created_by, updated_at: at }; } function updatedDefinition( diff --git a/packages/definitions/src/registry/definition-versions.test.ts b/packages/definitions/src/registry/definition-versions.test.ts index 702694b60..5f2a3a760 100644 --- a/packages/definitions/src/registry/definition-versions.test.ts +++ b/packages/definitions/src/registry/definition-versions.test.ts @@ -3,6 +3,7 @@ import { memoryLedger } from '@on.auto/operations/testing'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; +import { recordedAdmin } from '../testing/callers.ts'; import type { RecordedDefinitionEvent } from './definition-events.ts'; import { definitionVersionFold, definitionVersionIn } from './definition-versions.ts'; import { definitionsDecider } from './definitions-decider.ts'; @@ -20,12 +21,12 @@ function about(name: string, version: number): DefinitionContext { const history: readonly RecordedDefinitionEvent[] = [ { type: 'definition_created', - data: { content: { source: 'one' } }, + data: { content: { source: 'one' }, created_by: recordedAdmin }, context: about('greet', 1), }, { type: 'definition_created', - data: { content: { source: 'else' } }, + data: { content: { source: 'else' }, created_by: recordedAdmin }, context: about('other', 1), }, { @@ -64,6 +65,7 @@ describe('a read of one version of a definition of a playbook', () => { type: 'create', name: 'greet', content: { source: 'one' }, + created_by: recordedAdmin, ...at, }), ); diff --git a/packages/definitions/src/registry/definition.test.ts b/packages/definitions/src/registry/definition.test.ts index b5361043d..ee90b9ec8 100644 --- a/packages/definitions/src/registry/definition.test.ts +++ b/packages/definitions/src/registry/definition.test.ts @@ -17,7 +17,7 @@ const reasoningFunction: Definition = { source: 'Review the brief against the criteria.', overview: 'Reviews each campaign against its criteria before it goes out.', created_at: '2026-09-01T00:00:00.000Z', - created_by: 'author', + created_by: { id: 'author', name: 'author' }, updated_at: '2026-09-02T00:00:00.000Z', }; diff --git a/packages/definitions/src/registry/definition.ts b/packages/definitions/src/registry/definition.ts index ef81c119b..e28d05c0a 100644 --- a/packages/definitions/src/registry/definition.ts +++ b/packages/definitions/src/registry/definition.ts @@ -1,3 +1,4 @@ +import { RecordedCallerSchema } from '@on.auto/operations'; import { Schema } from 'effect'; import { functionTypeOrder } from '../capability/function-terminology.ts'; @@ -50,7 +51,7 @@ const listedDefinitionFields = { }), ), created_at: Schema.String.annotate({ description: 'When the definition was created, in ISO 8601 UTC' }), - created_by: Schema.String.annotate({ description: 'The id of the caller who created the definition' }), + created_by: RecordedCallerSchema.annotate({ description: 'The caller who created the definition, by id and name' }), updated_at: Schema.String.annotate({ description: 'When the definition last changed, in ISO 8601 UTC' }), retired_at: Schema.optionalKey( Schema.String.annotate({ description: 'When the definition was retired, in ISO 8601 UTC' }), diff --git a/packages/definitions/src/registry/definitions-decider.test.ts b/packages/definitions/src/registry/definitions-decider.test.ts index b92920971..07fea0fcd 100644 --- a/packages/definitions/src/registry/definitions-decider.test.ts +++ b/packages/definitions/src/registry/definitions-decider.test.ts @@ -2,6 +2,7 @@ import { Conflict, NotFound, type Context, type DefinitionContext } from '@on.au import { Result, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; +import { recordedAdmin } from '../testing/callers.ts'; import type { DefinitionCommand } from './definition-commands.ts'; import type { DefinitionContent, RecordedDefinitionEvent } from './definition-events.ts'; import { definitionsDecider, definitionTypeStreamOf } from './definitions-decider.ts'; @@ -29,7 +30,8 @@ function ofTheDefinition(name: string, when: Pick, version } function created(name: string, content: DefinitionContent): RecordedDefinition { - return { type: 'definition_created', data: { content }, context: ofTheDefinition(name, creation, 1) }; + const data = { content, created_by: recordedAdmin }; + return { type: 'definition_created', data, context: ofTheDefinition(name, creation, 1) }; } const greetCreated = created('greet', hello); @@ -58,7 +60,9 @@ function contextOf(command: DefinitionCommand, ...history: readonly RecordedDefi return echoDefinitions.context(command, registryAfter(...history)); } -const creatingGreet: DefinitionCommand = { type: 'create', name: 'greet', content: hello, ...creation }; +const asTheAdmin = { ...creation, created_by: recordedAdmin }; + +const creatingGreet: DefinitionCommand = { type: 'create', name: 'greet', content: hello, ...asTheAdmin }; function updatingGreet(content: DefinitionContent): DefinitionCommand { return { type: 'update', name: 'greet', content, ...change }; @@ -69,7 +73,7 @@ const retiringGreet: DefinitionCommand = { type: 'retire', name: 'greet', ...cha describe('creating a definition', () => { it('records its content, with the definition at version 1, who created it and when as its context', () => { expect(decided(creatingGreet)).toStrictEqual( - Result.succeed([{ type: 'definition_created', data: { content: hello } }]), + Result.succeed([{ type: 'definition_created', data: { content: hello, created_by: recordedAdmin } }]), ); expect(contextOf(creatingGreet)).toStrictEqual(greetCreated.context); }); @@ -195,7 +199,7 @@ describe('the definitions of a capability', () => { source: '{"greeting": "Howdy"}', overview: 'Greets with a howdy.', created_at: '2026-10-01T09:00:00.000Z', - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: '2026-10-03T08:00:00.000Z', retired_at: '2026-10-03T08:00:00.000Z', }, @@ -206,7 +210,7 @@ describe('the definitions of a capability', () => { source: '{"greeting": "Howdy"}', overview: 'Greets with a howdy.', created_at: '2026-10-01T09:00:00.000Z', - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: '2026-10-01T09:00:00.000Z', }, ]); @@ -266,7 +270,7 @@ describe('the definitions of a playbook that start on their own', () => { ]).toEqual([ Result.fail(beyondTheBound), Result.fail(beyondTheBound), - Result.succeed([{ type: 'definition_created', data: { content: hello } }]), + Result.succeed([{ type: 'definition_created', data: { content: hello, created_by: recordedAdmin } }]), ]); }); diff --git a/packages/definitions/src/registry/registry-decisions.ts b/packages/definitions/src/registry/registry-decisions.ts index 3423636f6..323358e35 100644 --- a/packages/definitions/src/registry/registry-decisions.ts +++ b/packages/definitions/src/registry/registry-decisions.ts @@ -71,7 +71,7 @@ function tooMany({ type, mostActive }: RegistryRules, active: number, saving: st function decideCreation( rules: RegistryRules, - { name, content }: DefinitionCreation & CommandMetadata, + { name, content, created_by }: DefinitionCreation & CommandMetadata, registry: DefinitionRegistry, ): Decision { const existing = registry.get(name); @@ -83,7 +83,9 @@ function decideCreation( return Result.fail(tooMany(rules, active, 'creating another')); } const beyond = beyondTheReactingBound(rules.type, registry, { name, content }); - return beyond === undefined ? recording({ type: 'definition_created', data: { content } }) : Result.fail(beyond); + return beyond === undefined + ? recording({ type: 'definition_created', data: { content, created_by } }) + : Result.fail(beyond); } function withTheSavedOverview(content: DefinitionContent, { overview }: StoredDefinition): DefinitionContent { diff --git a/packages/definitions/src/run-work/answer-command.test.ts b/packages/definitions/src/run-work/answer-command.test.ts index 8231e7538..c5bd97bb9 100644 --- a/packages/definitions/src/run-work/answer-command.test.ts +++ b/packages/definitions/src/run-work/answer-command.test.ts @@ -23,6 +23,8 @@ import { } from '../testing/asked-runs.ts'; import { relayedId, withHandOn } from '../testing/relaying.ts'; +const thePlaybook = { id: 'playbook:alpha', name: 'alpha' }; + describe('the answer to a request a run waits on', () => { it('is one fact on the run, kept by the run with who gave it and when, and the same answer again records nothing', async () => { const { record, recorded, state } = await asked(); @@ -32,12 +34,12 @@ describe('the answer to a request a run waits on', () => { expect(again.id).toBe(answered.id); expect((await recorded()).slice(2).map(({ type, data }) => [type, data])).toEqual([ - ['request_answered', { answer: { choice: 'approve' }, claimed_for: 'ada' }], + ['request_answered', { answer: { choice: 'approve' }, claimed_for: 'ada', answered_by: thePlaybook }], ]); expect((await state())?.answer).toEqual({ answer: { choice: 'approve' }, claimedFor: 'ada', - by: 'playbook:alpha', + by: thePlaybook, at: answered.at, }); }); @@ -53,7 +55,7 @@ describe('the answer to a request a run waits on', () => { expect((await state())?.answer).toEqual({ answer: { choice: 'reject' }, reply, - by: 'playbook:alpha', + by: thePlaybook, at: answered.at, }); }); @@ -143,13 +145,16 @@ describe('the answer a caller gives to a request a run waits on', () => { }; const answer = playbookBoundAnswerer(inAlpha); - await Effect.runPromise(answer(run.id.toUpperCase(), { answer: { choice: 'approve' } }, 'ada', lineage)); + const ada = { id: 'person:ada', name: 'Ada Lovelace' }; + await Effect.runPromise(answer(run.id.toUpperCase(), { answer: { choice: 'approve' } }, ada, lineage)); expect((await recorded()).slice(2).map(({ type, data, context }) => [type, data, context.by])).toEqual([ - ['request_answered', { answer: { choice: 'approve' } }, 'ada'], + ['request_answered', { answer: { choice: 'approve' }, answered_by: ada }, 'person:ada'], ]); - expect(await attempted(answer(run.id, { answer: { choice: 'reject' } }, 'grace', lineage))).toEqual( - Result.fail(answeredAlready), - ); + expect( + await attempted( + answer(run.id, { answer: { choice: 'reject' } }, { id: 'person:grace', name: 'Grace Hopper' }, lineage), + ), + ).toEqual(Result.fail(answeredAlready)); }); }); diff --git a/packages/definitions/src/run-work/work-command.test.ts b/packages/definitions/src/run-work/work-command.test.ts index e930186d9..7ab3b0ca3 100644 --- a/packages/definitions/src/run-work/work-command.test.ts +++ b/packages/definitions/src/run-work/work-command.test.ts @@ -15,6 +15,7 @@ import { question, } from '../testing/asked-runs.ts'; import { askedRequest } from '../testing/asking.ts'; +import { recordedAdmin } from '../testing/callers.ts'; describe('a run of a capability that answers by its input', () => { it('records that it finishes later from the input it is given, and finishes in its call for another', async () => { @@ -26,9 +27,9 @@ describe('a run of a capability that answers by its input', () => { { status: 'succeeded', output: { status: 'started' } }, ]); expect([(await now.recorded())[0]?.data, (await later.recorded()).map(({ type, data }) => [type, data])]).toEqual([ - { input: { now: true } }, + { input: { now: true }, started_by: recordedAdmin }, [ - ['run_started', { input: { campaign: 'Spring' }, finishes_later: true }], + ['run_started', { input: { campaign: 'Spring' }, started_by: recordedAdmin, finishes_later: true }], ['run_deferred', { record: { ...askedRequest, message: question } }], ], ]); diff --git a/packages/definitions/src/run-work/work-recorder.ts b/packages/definitions/src/run-work/work-recorder.ts index 8e9a0628d..79fd795d5 100644 --- a/packages/definitions/src/run-work/work-recorder.ts +++ b/packages/definitions/src/run-work/work-recorder.ts @@ -4,14 +4,16 @@ import { OrgIdSchema, playbookCallerOf, messageIdOf, + recordedCallerOf, streamPrefixOfPlaybook, type Lineage, + type RecordedCaller, type StreamReader, type StreamWriter, } from '@on.auto/operations'; import { DateTime, Effect, Schema } from 'effect'; -import type { AnswerFact, CapabilityFact } from '../runs/run-commands.ts'; +import type { CapabilityFact, GivenAnswer, RunAnswer, RunWork } from '../runs/run-commands.ts'; import { isChangedSinceRead, runDecider, runStreamNameOf, type RunDeciders } from '../runs/run-decider.ts'; import type { RunStreamAddress } from '../runs/run-settler.ts'; import { startedRunOf, type RecordedRunState } from '../runs/run-state.ts'; @@ -23,7 +25,7 @@ export interface RecordedWorkAt { export type Work = | { readonly type: 'work'; readonly fact: CapabilityFact } - | { readonly type: 'answer'; readonly answer: AnswerFact }; + | { readonly type: 'answer'; readonly answer: GivenAnswer }; export type WorkRule = (run: RecordedRunState) => Conflict | undefined; @@ -46,6 +48,14 @@ function admitted(): undefined { return undefined; } +function answeredBy(answer: GivenAnswer, by: RecordedCaller): RunAnswer { + return { type: 'answer', answer: { ...answer, answered_by: by } }; +} + +function recordedAs(work: Work, by: RecordedCaller): RunWork | RunAnswer { + return work.type === 'answer' ? answeredBy(work.answer, by) : work; +} + export function workRecorder(ledger: StreamReader & StreamWriter, deciders: RunDeciders): RecordWork { const recordedAsRead = Effect.fnUntraced(function* ( run: RunStreamAddress, @@ -65,7 +75,8 @@ export function workRecorder(ledger: StreamReader & StreamWriter, deciders: RunD return yield* refusal; } const at = DateTime.formatIso(yield* DateTime.now); - const command = { ...work, runId, by: playbookCallerOf(run).id, at }; + const thePlaybook = recordedCallerOf(playbookCallerOf(run)); + const command = { ...recordedAs(work, thePlaybook), runId, by: thePlaybook.id, at }; const { version } = yield* ledger .execute(stream, deciders.asRead, { readAt: read.version, command }, lineage) .pipe(Effect.catchTags({ not_found: Effect.die, cancelled: Effect.die })); @@ -75,7 +86,12 @@ export function workRecorder(ledger: StreamReader & StreamWriter, deciders: RunD recordedAsRead(run, work, lineage, rule).pipe(Effect.retry({ times: readsAgainAtMost, while: isChangedSinceRead })); } -export type AnswerRun = (id: string, answer: AnswerFact, by: string, lineage: Lineage) => Effect.Effect; +export type AnswerRun = ( + id: string, + answer: GivenAnswer, + by: RecordedCaller, + lineage: Lineage, +) => Effect.Effect; export function playbookBoundAnswerer(writer: StreamWriter): AnswerRun { return (id, answer, by, lineage) => @@ -83,7 +99,7 @@ export function playbookBoundAnswerer(writer: StreamWriter): AnswerRun { const runId = id.toLowerCase(); const at = DateTime.formatIso(yield* DateTime.now); yield* writer - .execute(runStreamNameOf(runId), runDecider, { type: 'answer', answer, runId, by, at }, lineage) + .execute(runStreamNameOf(runId), runDecider, { ...answeredBy(answer, by), runId, by: by.id, at }, lineage) .pipe(Effect.catchTags({ not_found: Effect.die, cancelled: Effect.die })); }); } diff --git a/packages/definitions/src/runs/caller-cancels.test.ts b/packages/definitions/src/runs/caller-cancels.test.ts index f3850aa20..df3e49d67 100644 --- a/packages/definitions/src/runs/caller-cancels.test.ts +++ b/packages/definitions/src/runs/caller-cancels.test.ts @@ -2,6 +2,7 @@ import { Conflict, NotFound, RunCancelled } from '@on.auto/operations'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; +import { recordedAdmin } from '../testing/callers.ts'; import { recordedWith, runStateAfter, testRunId } from '../testing/run-facts.ts'; import type { RunCommand } from './run-commands.ts'; import { runDecider } from './run-decider.ts'; @@ -15,7 +16,10 @@ const greeting = { definition_type: 'probe', name: 'plain', input: {} }; const ofPlain = { definitionType: 'probe', definitionName: 'plain', definitionVersion: 1 }; -const started = recordedWith({ ...start, ...ofPlain })({ type: 'run_started', data: { input: {} } }); +const started = recordedWith({ ...start, ...ofPlain })({ + type: 'run_started', + data: { started_by: recordedAdmin, input: {} }, +}); const reason = 'The run that waited for it ended first'; @@ -41,7 +45,14 @@ function cancelling(byItsCaller: boolean): RunCommand { }; } -const starting: RunCommand = { type: 'start', ...greeting, definition_version: 1, calls_tools: false, ...start }; +const starting: RunCommand = { + type: 'start', + ...greeting, + started_by: recordedAdmin, + definition_version: 1, + calls_tools: false, + ...start, +}; const cancelledFirst = new RunCancelled({ detail: reason, kind: 'parent_ended' }); diff --git a/packages/definitions/src/runs/cancelled-calls.test.ts b/packages/definitions/src/runs/cancelled-calls.test.ts index b789c6944..9a44d79e6 100644 --- a/packages/definitions/src/runs/cancelled-calls.test.ts +++ b/packages/definitions/src/runs/cancelled-calls.test.ts @@ -3,6 +3,7 @@ import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; import { modelCallAnswered, modelCallSent } from '../journal-testing/model-calls.ts'; +import { recordedAdmin } from '../testing/callers.ts'; import { recordedWith, runStateAfter, testRunId } from '../testing/run-facts.ts'; import type { CallFact, ModelCallFact, RunCommand } from './run-commands.ts'; import { runDecider } from './run-decider.ts'; @@ -23,9 +24,9 @@ function decided(fact: CallFact, ...history: readonly RecordedRunEvent[]) { const input = { who: 'Ada' }; -const startedToResume = atTheStart({ type: 'run_started', data: { input, resumes: true } }); +const startedToResume = atTheStart({ type: 'run_started', data: { started_by: recordedAdmin, input, resumes: true } }); -const startedInItsCall = atTheStart({ type: 'run_started', data: { input } }); +const startedInItsCall = atTheStart({ type: 'run_started', data: { started_by: recordedAdmin, input } }); const cancelAsked = meanwhile({ type: 'run_cancel_requested', data: { kind: 'requested', reason: 'Not needed' } }); diff --git a/packages/definitions/src/runs/run-calls.test.ts b/packages/definitions/src/runs/run-calls.test.ts index 4a0d009ed..79c7b451f 100644 --- a/packages/definitions/src/runs/run-calls.test.ts +++ b/packages/definitions/src/runs/run-calls.test.ts @@ -3,6 +3,7 @@ import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; import { modelCallAnswered, modelCallSent, modelCallTimedOut } from '../journal-testing/model-calls.ts'; +import { recordedAdmin } from '../testing/callers.ts'; import { recordedWith, runStateAfter, testRunId } from '../testing/run-facts.ts'; import type { CallFact, CallStartedFact, ModelCallFact, RunCommand, ToolCallFact } from './run-commands.ts'; import { runDecider } from './run-decider.ts'; @@ -23,7 +24,7 @@ const meanwhile = recordedWith({ ...during, ...ofGreet }); const greeting = { definition_type: 'echo', name: 'greet', input: { who: 'Ada', tags: ['a', 'b'] } }; -const started = atTheStart({ type: 'run_started', data: { input: greeting.input } }); +const started = atTheStart({ type: 'run_started', data: { started_by: recordedAdmin, input: greeting.input } }); const succeeded = atTheFinish({ type: 'run_succeeded', data: { output: 'Hello Ada', record: {} } }); @@ -48,7 +49,7 @@ function decided(command: RunCommand, ...history: readonly RecordedRunEvent[]) { } function starting(): RunCommand { - return { type: 'start', ...greeting, calls_tools: false, definition_version: 1, ...start }; + return { type: 'start', ...greeting, started_by: recordedAdmin, calls_tools: false, definition_version: 1, ...start }; } const sent = { diff --git a/packages/definitions/src/runs/run-commands.ts b/packages/definitions/src/runs/run-commands.ts index 86f7e7088..d0d58b1fa 100644 --- a/packages/definitions/src/runs/run-commands.ts +++ b/packages/definitions/src/runs/run-commands.ts @@ -1,4 +1,4 @@ -import type { CallLink, StartingTrigger } from '@on.auto/operations'; +import type { CallLink, RecordedCaller, StartingTrigger } from '@on.auto/operations'; import type { Schema } from 'effect'; import type { OutputShapeName } from '../shapes/shape-names.ts'; @@ -21,6 +21,7 @@ export interface RunRequest { export interface RunStart extends RunRequest { readonly type: 'start'; + readonly started_by: RecordedCaller; readonly definition_version: number; readonly calls_tools: boolean; readonly finishes_later?: boolean; @@ -54,6 +55,8 @@ export interface CapabilityFact { export type AnswerFact = RequestAnswered['data']; +export type GivenAnswer = Omit; + export interface InterruptedAttempt { readonly type: 'run_interrupted'; readonly resumes?: true; diff --git a/packages/definitions/src/runs/run-decider.test.ts b/packages/definitions/src/runs/run-decider.test.ts index e434eb4d6..90dd0bcee 100644 --- a/packages/definitions/src/runs/run-decider.test.ts +++ b/packages/definitions/src/runs/run-decider.test.ts @@ -2,6 +2,7 @@ import { Conflict, type Context } from '@on.auto/operations'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; +import { recordedAdmin } from '../testing/callers.ts'; import { recordedWith, runStateAfter, testRunId } from '../testing/run-facts.ts'; import type { RunCommand, RunResult } from './run-commands.ts'; import { runDecider, runStreamNameOf } from './run-decider.ts'; @@ -16,7 +17,7 @@ const ofGreet = { definitionType: 'echo', definitionName: 'greet', definitionVer const greeting = { definition_type: 'echo', name: 'greet', input: { who: 'Ada', tags: ['a', 'b'] } }; -const started: RunEvent = { type: 'run_started', data: { input: greeting.input } }; +const started: RunEvent = { type: 'run_started', data: { started_by: recordedAdmin, input: greeting.input } }; const succeeded: RunResult = { type: 'run_succeeded', data: { output: 'Hello Ada', record: { model: 'x' } } }; @@ -60,7 +61,15 @@ function decided(command: RunCommand, ...history: readonly RecordedRunEvent[]) { } function starting(request: object = {}, version = 1): RunCommand { - return { type: 'start', ...greeting, calls_tools: false, ...request, definition_version: version, ...start }; + return { + type: 'start', + ...greeting, + started_by: recordedAdmin, + calls_tools: false, + ...request, + definition_version: version, + ...start, + }; } function finishing(result: RunResult): RunCommand { @@ -187,7 +196,7 @@ describe('a run', () => { status: 'rejected', rejection: { reason: 'unavailable', detail: 'The model is busy' }, started_at: start.at, - started_by: start.by, + started_by: recordedAdmin, finished_at: finish.at, }, ...recordedState, @@ -209,7 +218,7 @@ describe('a run started again', () => { status: 'succeeded', output: 'Hello Ada', started_at: start.at, - started_by: start.by, + started_by: recordedAdmin, finished_at: finish.at, }, ...recordedState, @@ -227,7 +236,7 @@ describe('the attempts of a run', () => { definition_version: 1, status: 'failed', started_at: start.at, - started_by: start.by, + started_by: recordedAdmin, finished_at: finish.at, }); }); diff --git a/packages/definitions/src/runs/run-decisions.ts b/packages/definitions/src/runs/run-decisions.ts index 8e3bfe24d..db0f47e7d 100644 --- a/packages/definitions/src/runs/run-decisions.ts +++ b/packages/definitions/src/runs/run-decisions.ts @@ -124,11 +124,12 @@ function startedCallingToolsBefore(start: RunStart, state: RunState): boolean { return state !== undefined && isRunning(state) && (state.callsTools || start.calls_tools); } -function startedEvent({ input, calls_tools, finishes_later, resumes, output_shape }: RunStart): RunEvent { +function startedEvent({ input, started_by, calls_tools, finishes_later, resumes, output_shape }: RunStart): RunEvent { return { type: 'run_started', data: { input, + started_by, ...(calls_tools ? { calls_tools } : {}), ...(finishes_later === true ? { finishes_later } : {}), ...(resumes === true ? { resumes } : {}), diff --git a/packages/definitions/src/runs/run-deferral.test.ts b/packages/definitions/src/runs/run-deferral.test.ts index d8068a0d6..584a55d7d 100644 --- a/packages/definitions/src/runs/run-deferral.test.ts +++ b/packages/definitions/src/runs/run-deferral.test.ts @@ -2,6 +2,7 @@ import { Conflict, NotFound } from '@on.auto/operations'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; +import { recordedAdmin } from '../testing/callers.ts'; import { recordedWith, runStateAfter, testRunId } from '../testing/run-facts.ts'; import type { RunCommand, RunOutcome, RunResult } from './run-commands.ts'; import { runDecider } from './run-decider.ts'; @@ -17,7 +18,7 @@ const ofHandOn = { definitionType: 'relay', definitionName: 'hand-on', definitio const atTheStart = recordedWith({ ...start, ...ofHandOn }); -const started = atTheStart({ type: 'run_started', data: { input: greeting.input } }); +const started = atTheStart({ type: 'run_started', data: { started_by: recordedAdmin, input: greeting.input } }); const deferral: RunOutcome = { type: 'run_deferred', data: { record: { run: 'r-1' } } }; @@ -50,7 +51,14 @@ function settling(result: RunResult, by = 'playbook:alpha'): RunCommand { return { type: 'settle', result, runId: testRunId, by, at: later }; } -const starting: RunCommand = { type: 'start', ...greeting, definition_version: 1, calls_tools: false, ...start }; +const starting: RunCommand = { + type: 'start', + ...greeting, + started_by: recordedAdmin, + definition_version: 1, + calls_tools: false, + ...start, +}; const anotherResult = new Conflict({ detail: 'The run already ended with another result' }); @@ -152,18 +160,23 @@ describe('a settlement that does not land', () => { it('as unavailable lets a call with its id start it again', () => { expect(decided(starting, started, deferred, unavailable)).toStrictEqual( - Result.succeed([{ type: 'run_started', data: { input: greeting.input } }]), + Result.succeed([{ type: 'run_started', data: { started_by: recordedAdmin, input: greeting.input } }]), ); expect(stateAfter(started, deferred, unavailable, started)).toMatchObject({ deferred: false }); }); }); -const startedLater = atTheStart({ type: 'run_started', data: { input: greeting.input, finishes_later: true } }); +const startedLater = atTheStart({ + type: 'run_started', + data: { started_by: recordedAdmin, input: greeting.input, finishes_later: true }, +}); describe('a run whose start says it finishes later', () => { it('is recorded with that on its start', () => { expect(decided({ ...starting, finishes_later: true })).toStrictEqual( - Result.succeed([{ type: 'run_started', data: { input: greeting.input, finishes_later: true } }]), + Result.succeed([ + { type: 'run_started', data: { started_by: recordedAdmin, input: greeting.input, finishes_later: true } }, + ]), ); expect(stateAfter(startedLater)).toMatchObject({ finishesLater: true, deferred: false }); }); diff --git a/packages/definitions/src/runs/run-depth.test.ts b/packages/definitions/src/runs/run-depth.test.ts index 9484a5947..8c923169c 100644 --- a/packages/definitions/src/runs/run-depth.test.ts +++ b/packages/definitions/src/runs/run-depth.test.ts @@ -1,6 +1,7 @@ import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; +import { recordedAdmin } from '../testing/callers.ts'; import { recordedWith, runStateAfter, testRunId } from '../testing/run-facts.ts'; import type { RunCommand } from './run-commands.ts'; import { runDecider } from './run-decider.ts'; @@ -23,10 +24,18 @@ interface StartOptions { } function starting(options: StartOptions): RunCommand { - return { type: 'start', ...greeting, calls_tools: false, definition_version: 1, ...start, ...options }; + return { + type: 'start', + ...greeting, + started_by: recordedAdmin, + calls_tools: false, + definition_version: 1, + ...start, + ...options, + }; } -const started: RunEvent = { type: 'run_started', data: { input: greeting.input } }; +const started: RunEvent = { type: 'run_started', data: { started_by: recordedAdmin, input: greeting.input } }; const startedDeep = recordedWith({ ...start, ...ofGreet, depth: 2 })(started); diff --git a/packages/definitions/src/runs/run-endings.test.ts b/packages/definitions/src/runs/run-endings.test.ts index a4705441b..6b646b032 100644 --- a/packages/definitions/src/runs/run-endings.test.ts +++ b/packages/definitions/src/runs/run-endings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { cancelRequestOf, lastEndingOf, runEndingOf } from '../index.ts'; +import { recordedAdmin } from '../testing/callers.ts'; const calledBy = { runId: '0199a3c4-7d2e-7c1a-9b3f-000000000001', reference: '/do/0/check', run: 1 }; @@ -14,7 +15,7 @@ const context = { calledBy, }; -const started = { type: 'run_started', data: { input: {} }, context }; +const started = { type: 'run_started', data: { started_by: recordedAdmin, input: {} }, context }; const succeeded = { type: 'run_succeeded', data: { output: 'done', record: {} }, context }; diff --git a/packages/definitions/src/runs/run-events.ts b/packages/definitions/src/runs/run-events.ts index 2ee7162a4..83fecbd3f 100644 --- a/packages/definitions/src/runs/run-events.ts +++ b/packages/definitions/src/runs/run-events.ts @@ -1,4 +1,10 @@ -import { ActedOnRunContextSchema, RunContextSchema, factOf, type Decided } from '@on.auto/operations'; +import { + ActedOnRunContextSchema, + RecordedCallerSchema, + RunContextSchema, + factOf, + type Decided, +} from '@on.auto/operations'; import { CallAnsweredSchema, CallFailedSchema, CallStartedSchema } from '@on.auto/tool-servers'; import { Schema, SchemaTransformation } from 'effect'; @@ -20,6 +26,7 @@ export const RequestAnsweredSchema = Schema.Struct({ answer: Schema.Json, claimed_for: Schema.optionalKey(Schema.String), reply: Schema.optionalKey(ReplyIdentitySchema), + answered_by: RecordedCallerSchema, }); const coreFactSchemas = [ @@ -27,6 +34,7 @@ const coreFactSchemas = [ 'run_started', Schema.Struct({ input: Schema.Json, + started_by: RecordedCallerSchema, calls_tools: Schema.optionalKey(Schema.Literal(true)), finishes_later: Schema.optionalKey(Schema.Literal(true)), resumes: Schema.optionalKey(Schema.Literal(true)), diff --git a/packages/definitions/src/runs/run-resumes.test.ts b/packages/definitions/src/runs/run-resumes.test.ts index c85fff7e7..9c5553a76 100644 --- a/packages/definitions/src/runs/run-resumes.test.ts +++ b/packages/definitions/src/runs/run-resumes.test.ts @@ -2,6 +2,7 @@ import type { Context } from '@on.auto/operations'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; +import { recordedAdmin } from '../testing/callers.ts'; import { recordedWith, runStateAfter, testRunId } from '../testing/run-facts.ts'; import type { RunCommand } from './run-commands.ts'; import { runDecider } from './run-decider.ts'; @@ -14,7 +15,7 @@ const atTheStart: Context = { ...acted, definitionType: 'resumer', definitionNam const recorded = recordedWith(atTheStart); -const resumable: RunEvent = { type: 'run_started', data: { input: {}, resumes: true } }; +const resumable: RunEvent = { type: 'run_started', data: { started_by: recordedAdmin, input: {}, resumes: true } }; function resumingAs(attempt: number): RunCommand { return { type: 'resume', attempt, ...acted }; @@ -43,7 +44,7 @@ describe('the resume of a run by an attempt after the one that died', () => { expect([ decided(resumingAs(2)), decided(resumingAs(2), resumable, { type: 'run_succeeded', data: { output: null, record: {} } }), - decided(resumingAs(2), { type: 'run_started', data: { input: {} } }), + decided(resumingAs(2), { type: 'run_started', data: { started_by: recordedAdmin, input: {} } }), decided(resumingAs(2), resumable, { type: 'attempt_resumed', data: { attempt: 2 } }), ]).toStrictEqual([ Result.fail(stillRunning), diff --git a/packages/definitions/src/runs/run-settler.test.ts b/packages/definitions/src/runs/run-settler.test.ts index 1f6198972..2224800e9 100644 --- a/packages/definitions/src/runs/run-settler.test.ts +++ b/packages/definitions/src/runs/run-settler.test.ts @@ -13,7 +13,7 @@ const settled = { name: 'hand-on', definition_version: 1, started_at: firstMoment, - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: settledAt, }; diff --git a/packages/definitions/src/runs/run-starts.test.ts b/packages/definitions/src/runs/run-starts.test.ts index 6ab4e913b..94a030c74 100644 --- a/packages/definitions/src/runs/run-starts.test.ts +++ b/packages/definitions/src/runs/run-starts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { recordedAdmin } from '../testing/callers.ts'; import { runStartedOf } from './run-starts.ts'; const context = { @@ -16,10 +17,18 @@ describe('the start of a run read from its record', () => { const { definitionName: _name, ...withoutItsDefinition } = context; expect([ - runStartedOf({ type: 'run_started', data: { input: {} }, context: { ...context, depth: 2 } }), + runStartedOf({ + type: 'run_started', + data: { started_by: recordedAdmin, input: {} }, + context: { ...context, depth: 2 }, + }), runStartedOf({ type: 'run_failed', data: {}, context }), - runStartedOf({ type: 'run_started', data: { input: {} } }), - runStartedOf({ type: 'run_started', data: { input: {} }, context: withoutItsDefinition }), + runStartedOf({ type: 'run_started', data: { started_by: recordedAdmin, input: {} } }), + runStartedOf({ + type: 'run_started', + data: { started_by: recordedAdmin, input: {} }, + context: withoutItsDefinition, + }), runStartedOf('not a record'), ]).toEqual([{ ...context, depth: 2 }, undefined, undefined, undefined, undefined]); }); diff --git a/packages/definitions/src/runs/run-state.ts b/packages/definitions/src/runs/run-state.ts index 6c5e2eeca..bb9e6c367 100644 --- a/packages/definitions/src/runs/run-state.ts +++ b/packages/definitions/src/runs/run-state.ts @@ -1,4 +1,4 @@ -import type { CalledBy, RunContext, StartingTrigger } from '@on.auto/operations'; +import type { CalledBy, RecordedCaller, RunContext, StartingTrigger } from '@on.auto/operations'; import type { Schema } from 'effect'; import { numberingAfter, numberingFrom, type CallNumbering } from '../journal/call-numbering.ts'; @@ -16,7 +16,7 @@ export interface RecordedAnswer { readonly answer: Schema.Json; readonly claimedFor?: string; readonly reply?: ReplyIdentity; - readonly by: string; + readonly by: RecordedCaller; readonly at: string; } @@ -82,8 +82,8 @@ function definitionOf({ definitionType, definitionName, definitionVersion }: Run type RecordedFinish = RecordedRunFact<'run_succeeded' | 'run_rejected' | 'run_failed'>; function startedRun({ data, context }: RecordedRunFact<'run_started'>, earlier: RunState): RecordedRunState { - const { input, calls_tools, finishes_later, resumes, output_shape } = data; - const { at, by, depth = 0, callDepth = 0, calledBy, trigger } = context; + const { input, started_by, calls_tools, finishes_later, resumes, output_shape } = data; + const { at, depth = 0, callDepth = 0, calledBy, trigger } = context; return { attempt: firstAttempt, input, @@ -92,7 +92,7 @@ function startedRun({ data, context }: RecordedRunFact<'run_started'>, earlier: ...(output_shape === undefined ? {} : { output_shape }), status: 'started', started_at: at, - started_by: by, + started_by, }, finishesLater: finishes_later === true, deferred: false, @@ -154,16 +154,16 @@ function finishedRun(state: RecordedRunState, event: RecordedFinish): RecordedRu export interface RecordedAnswerFact { readonly data: RecordedRunFact<'request_answered'>['data']; - readonly context: Pick; + readonly context: Pick; } export function recordedAnswerOf({ data, context }: RecordedAnswerFact): RecordedAnswer { - const { answer, claimed_for: claimedFor, reply } = data; + const { answer, claimed_for: claimedFor, reply, answered_by: by } = data; return { answer, ...(claimedFor === undefined ? {} : { claimedFor }), ...(reply === undefined ? {} : { reply }), - by: context.by, + by, at: context.at, }; } diff --git a/packages/definitions/src/runs/run-tool-starts.test.ts b/packages/definitions/src/runs/run-tool-starts.test.ts index 4aa6b3260..6a0c6c5fc 100644 --- a/packages/definitions/src/runs/run-tool-starts.test.ts +++ b/packages/definitions/src/runs/run-tool-starts.test.ts @@ -2,6 +2,7 @@ import { Conflict } from '@on.auto/operations'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; +import { recordedAdmin } from '../testing/callers.ts'; import { recordedWith, runStateAfter, testRunId } from '../testing/run-facts.ts'; import type { RunCommand } from './run-commands.ts'; import { runDecider } from './run-decider.ts'; @@ -17,7 +18,7 @@ const atTheFinish = recordedWith({ ...start, ...ofGreet, at: '2026-10-01T09:00:0 const greeting = { definition_type: 'echo', name: 'greet', input: { who: 'Ada', tags: ['a', 'b'] } }; -const started = atTheStart({ type: 'run_started', data: { input: greeting.input } }); +const started = atTheStart({ type: 'run_started', data: { started_by: recordedAdmin, input: greeting.input } }); const succeeded = atTheFinish({ type: 'run_succeeded', data: { output: 'Hello Ada', record: {} } }); @@ -41,7 +42,15 @@ function decided(command: RunCommand, ...history: readonly RecordedRunEvent[]) { } function starting(request: object = {}): RunCommand { - return { type: 'start', ...greeting, calls_tools: false, ...request, definition_version: 1, ...start }; + return { + type: 'start', + ...greeting, + started_by: recordedAdmin, + calls_tools: false, + ...request, + definition_version: 1, + ...start, + }; } const startedCallingTools = new Conflict({ @@ -53,7 +62,10 @@ const startedCallingTools = new Conflict({ describe('starting a run whose definition calls tools', () => { const callingTools = starting({ calls_tools: true }); - const startOfTools: RunEvent = { type: 'run_started', data: { input: greeting.input, calls_tools: true } }; + const startOfTools: RunEvent = { + type: 'run_started', + data: { started_by: recordedAdmin, input: greeting.input, calls_tools: true }, + }; const startedWithTools = atTheStart(startOfTools); diff --git a/packages/definitions/src/runs/run.test.ts b/packages/definitions/src/runs/run.test.ts index 9b426d3d5..63325b2bc 100644 --- a/packages/definitions/src/runs/run.test.ts +++ b/packages/definitions/src/runs/run.test.ts @@ -10,7 +10,7 @@ const functionRun: Run = { status: 'succeeded', output: 'The brief meets the criteria.', started_at: '2026-09-02T00:00:00.000Z', - started_by: 'author', + started_by: { id: 'author', name: 'author' }, finished_at: '2026-09-02T00:01:00.000Z', }; diff --git a/packages/definitions/src/runs/run.ts b/packages/definitions/src/runs/run.ts index 53c27e17f..78b89c380 100644 --- a/packages/definitions/src/runs/run.ts +++ b/packages/definitions/src/runs/run.ts @@ -3,6 +3,7 @@ import { ConflictKindSchema, InvalidInputKindSchema, IssueSchema, + RecordedCallerSchema, UnansweredKindSchema, RejectionBecauseSchema, UnavailableKindSchema, @@ -105,7 +106,7 @@ export const RunSchema = Schema.Struct({ ), rejection: Schema.optionalKey(RunRejectionSchema), started_at: Schema.String.annotate({ description: 'When the run started, in ISO 8601 UTC' }), - started_by: Schema.String.annotate({ description: 'The id of the caller who started the run' }), + started_by: RecordedCallerSchema.annotate({ description: 'The caller who started the run, by id and name' }), finished_at: Schema.optionalKey(Schema.String.annotate({ description: 'When the run finished, in ISO 8601 UTC' })), }).annotate({ identifier: 'Run', description: 'One run of a definition with an input, and how it ended' }); diff --git a/packages/definitions/src/testing/callers.ts b/packages/definitions/src/testing/callers.ts index dacc1eea3..06eb267b1 100644 --- a/packages/definitions/src/testing/callers.ts +++ b/packages/definitions/src/testing/callers.ts @@ -1,9 +1,20 @@ -import { allPermissions, type CallerIdentity } from '@on.auto/operations'; +import { allPermissions, recordedCallerOf, type CallerIdentity } from '@on.auto/operations'; -export const acmeAdmin: CallerIdentity = { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }; +export const acmeAdmin: CallerIdentity = { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: allPermissions, + playbooks: '*', +}; + +export const recordedAdmin = recordedCallerOf(acmeAdmin); + +export const adaOfAcme: CallerIdentity = { ...acmeAdmin, id: 'person:user_2ada', name: 'Ada Lovelace' }; export const acmeReader: CallerIdentity = { id: 'acme-reader', + name: 'acme-reader', org: 'acme', permissions: ['playbook:read'], playbooks: '*', @@ -11,9 +22,10 @@ export const acmeReader: CallerIdentity = { export const acmeAlphaKeeper: CallerIdentity = { id: 'acme-alpha-keeper', + name: 'acme-alpha-keeper', org: 'acme', permissions: ['playbook:read', 'playbook:write'], playbooks: ['alpha'], }; -export const globexAdmin: CallerIdentity = { ...acmeAdmin, id: 'globex-admin', org: 'globex' }; +export const globexAdmin: CallerIdentity = { ...acmeAdmin, id: 'globex-admin', name: 'globex-admin', org: 'globex' }; diff --git a/packages/definitions/src/testing/resumed-runs.ts b/packages/definitions/src/testing/resumed-runs.ts index 2c61bebe6..5a5a72a3f 100644 --- a/packages/definitions/src/testing/resumed-runs.ts +++ b/packages/definitions/src/testing/resumed-runs.ts @@ -25,6 +25,7 @@ export const startOfTheStep = { definition_type: 'resumer', name: 'step', input: {}, + started_by: { id: acmeAdmin.id, name: acmeAdmin.name }, definition_version: 1, calls_tools: true, resumes: true, diff --git a/packages/definitions/src/used-by/definition-users.test.ts b/packages/definitions/src/used-by/definition-users.test.ts index 1dfb1fef3..c08caab98 100644 --- a/packages/definitions/src/used-by/definition-users.test.ts +++ b/packages/definitions/src/used-by/definition-users.test.ts @@ -14,7 +14,7 @@ function saved(name: string, details?: Schema.JsonObject, status: 'active' | 're status, source: '', created_at: at, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: at, ...(details === undefined ? {} : { details }), }; diff --git a/packages/identity/README.md b/packages/identity/README.md index c95cc477d..e6258aa54 100644 --- a/packages/identity/README.md +++ b/packages/identity/README.md @@ -1,10 +1,10 @@ # @on.auto/identity -Who calls the runtime: a key, the local developer, or a signed-in person the Auto identity service vouches for. `authenticatorFor` of `src/index.ts` takes the settings, the address the server listens on, the API keys the operator configured, the source a presented key is looked up in and, where sign-in is on, the identity service, and answers the access mode and an asynchronous `authenticate`, which takes the credential a request presents, the path of the MCP endpoint it reached, if any, and its `Origin`, if it has one, and answers its `Authentication`: the `Principal` of the caller, or a `Refusal` whose `because` says why a token was refused, in words that name a rule or a claim and never a value of the token (`isRefusal` tells the two apart). A principal holds its own caller, `own`, and answers the caller of an org with `callerIn(org)`, or `undefined` for an org it does not admit: a key admits its own org, the local developer every org, whatever credential the request presents, and a signed-in person the org their token names, or, where it names none, the org whose id is their subject. The access mode follows from what is configured, and `accessModeOf` of `src/access/access-mode.ts` is the one place that says which, for the authenticator and for the server's settings that follow from it: `credentials` with a key or with sign-in, `closed` when `API_KEYS` is set and lists no keys without sign-in, and with neither `local` on a loopback address, `localhost`, `127.0.0.0/8` or `::1` (`listensOnlyOnLoopback` of `src/access/loopback.ts`), a rule wider than the three names by which a URL or a `Host` header names the loopback, `namesTheLoopback` of `@on.auto/operations`, since a server that listens on any address of `127.0.0.0/8` is reachable from this computer alone. On any other address it is `local` too where the operator declares local mode, `accessMode: 'local'` (`DeclaredAccessMode`), for a server whose port only this computer reaches though it listens on every address of its own, such as a container whose port is published on `127.0.0.1` alone, which it cannot see; a key or sign-in still wins over the declaration. On any other address with neither and no declaration it names no mode, and `authenticatorFor` throws `ReachableWithoutCredentialsError`, which names the `HOST` and says that a server reachable beyond this computer needs `PUBLIC_URL`, to sign people in, or `API_KEYS`, or, where only this computer reaches its port, `ACCESS_MODE=local` with a `PUBLIC_URL` on the loopback, so the server stops before it listens. The server reads `listensOnlyOnLoopback` too, to refuse the declaration on another address without a `PUBLIC_URL` its links can point at. The key command, `src/key-command.ts`, prints a new key once and the `API_KEYS` entry that admits it. +Who calls the runtime: a key, the local developer, or a signed-in person the Auto identity service vouches for. `authenticatorFor` of `src/index.ts` takes the settings, the address the server listens on, the API keys the operator configured, the source a presented key is looked up in and, where sign-in is on, the identity service, and answers the access mode and an asynchronous `authenticate`, which takes the credential a request presents, the path of the MCP endpoint it reached, if any, and its `Origin`, if it has one, and answers its `Authentication`: the `Principal` of the caller, or a `Refusal` whose `because` says why a token was refused, in words that name a rule or a claim and never a value of the token (`isRefusal` tells the two apart). A principal holds its own caller, `own`, and answers the caller of an org with `callerIn(org)`, or `undefined` for an org it does not admit: a key admits its own org, the local developer every org, whatever credential the request presents, and a signed-in person the org their token names, or, where it names none, the org whose id is their subject. Each caller has a `name` beside its `id`, the caller in words, which what it does records: a signed-in person's name as the `name` claim of their token gives it, a key's id, and for the local developer the name of the account the server runs as on the computer, `os.userInfo().username`, or `local` where the computer names none or an empty one; `localAccount` of the settings reads another account, as a test does. `isKeyHolder` tells the caller of a key from a signed-in person, the local developer and a playbook acting as itself, since a key's id holds no colon and is not `local`. The access mode follows from what is configured, and `accessModeOf` of `src/access/access-mode.ts` is the one place that says which, for the authenticator and for the server's settings that follow from it: `credentials` with a key or with sign-in, `closed` when `API_KEYS` is set and lists no keys without sign-in, and with neither `local` on a loopback address, `localhost`, `127.0.0.0/8` or `::1` (`listensOnlyOnLoopback` of `src/access/loopback.ts`), a rule wider than the three names by which a URL or a `Host` header names the loopback, `namesTheLoopback` of `@on.auto/operations`, since a server that listens on any address of `127.0.0.0/8` is reachable from this computer alone. On any other address it is `local` too where the operator declares local mode, `accessMode: 'local'` (`DeclaredAccessMode`), for a server whose port only this computer reaches though it listens on every address of its own, such as a container whose port is published on `127.0.0.1` alone, which it cannot see; a key or sign-in still wins over the declaration. On any other address with neither and no declaration it names no mode, and `authenticatorFor` throws `ReachableWithoutCredentialsError`, which names the `HOST` and says that a server reachable beyond this computer needs `PUBLIC_URL`, to sign people in, or `API_KEYS`, or, where only this computer reaches its port, `ACCESS_MODE=local` with a `PUBLIC_URL` on the loopback, so the server stops before it listens. The server reads `listensOnlyOnLoopback` too, to refuse the declaration on another address without a `PUBLIC_URL` its links can point at. The key command, `src/key-command.ts`, prints a new key once and the `API_KEYS` entry that admits it. ## Keys -A key is `abk__`, stored as the SHA-256 of the whole key with its org, permissions and playbooks, and verified in constant time. The org `local` is local mode's and the id `local` the local developer's, so `API_KEYS` and the key command refuse both. +A key is `abk__`, stored as the SHA-256 of the whole key with its org, permissions and playbooks, and verified in constant time. Its caller is named by its id, so an entry of `API_KEYS` names no caller. The org `local` is local mode's and the id `local` the local developer's, so `API_KEYS` and the key command refuse both. Keys come from a source, `ApiKeySource` of `src/keys/api-key-source.ts`, whose `keyOf(id)` answers the entry of an id, its digest, org, permissions and playbooks, or nothing. On every request that presents a key, the authenticator asks the source for the id the key names, and asks nothing for a credential that is not of a key's form, then compares in constant time the digest of the key with the entry's, or with the digest of no key where the source answers nothing, so a key the source does not know is refused as a wrong secret is. The list the operator configured, `API_KEYS` or `api_keys` of the configuration file, is the one source today, `configuredApiKeys`, which the server composes and the authenticator takes when it is given no other; a source that keeps its keys elsewhere admits a key it issues and refuses a key it revokes while the server runs, since the authenticator holds no entry of its own. The configured list alone decides the access mode, as `accessModeOf` reads it, and no source is asked in local mode, where every request is the local developer's, or in a closed server, which admits nobody. @@ -12,9 +12,9 @@ Keys come from a source, `ApiKeySource` of `src/keys/api-key-source.ts`, whose ` `identityServiceOf` of `src/identity-service/identity-service.ts` takes the issuer, `autoIdentityService` unless a development runtime names another, `PUBLIC_URL`, the Studio settings and `ALLOWED_ORIGINS`, which the server hands it from its settings as it hands them to the API. A bearer that is not a key is one of two forms of token. An access token holds `aud`, and is what an agent or an agent gateway, a third party acting for a person, obtains through the authorization flow: it is taken for its audience, with the permissions among its scopes. A session holds no `aud`, and is what the identity service gives a page a person signs in to, such as the studio, Auto's own app: it is taken when its `azp` claim names an origin a page may come from and the request that presents it comes from that origin, so it serves its own page and never an agent, and gives every permission in its organization. `pageOriginsOf` of `src/access/page-origins.ts` is the one definition of those origins: the Studio host's, then the runtime's own origin where the runtime serves Studio, and `ALLOWED_ORIGINS`. The identity service gives it `PUBLIC_URL` as the runtime's own origin, and the API's `Origin` check and its CORS, which read it as well, give it the origin the API answers at. `src/access/studio.ts` holds the Studio settings, `StudioSettings`: `origin`, the Studio host, whose default is `defaultStudioOrigin`, `https://studio.on.auto`, and `served`, whether the runtime serves Studio itself at `servedStudioPath`, `/studio/`, since a page on a public origin may not reach an address on the person's machine. `studioBaseOf` is the one base of Studio's address that the runtime writes into a link: `{own origin}/studio/` where it serves Studio, and the Studio host's root otherwise. `signInScopes` are the scopes the runtime asks a client to request of the identity service: `user:org:read`, which binds the access token to the organization the person signs in to, as the `org_id` claim, and the four permissions, `allPermissions` of `@on.auto/operations`, the one definition of them, which the identity service defines as scopes of its own, each with a sentence its consent screen shows. At start it takes the issuer's key set, the signing keys the tokens are verified with, from an `IssuerKeySource` of `src/identity-service/issuer-keys.ts`, the `issuerKeys` of its settings, which the server composes from its own: `discoveredIssuerKeys`, the default, reads the issuer's metadata, from the addresses the MCP authorization specification orders, and the key set at the `jwks_uri` it names; `issuerKeysAt(url)` reads the key set at a URL the operator gives, and no metadata, for an issuer that publishes its keys another way; and `issuerKeysGiven(keySet)` takes a key set the operator gives, of `IssuerKeySetSchema`, a `keys` array of at least one key, each naming its `kty` and kept with every member it holds, and reads nothing. Each read is within 64 KiB and 10 seconds and follows no redirect (`src/identity-service/issuer-requests.ts`), and the identity service stops with `UnusableIdentityServiceError` naming the service when one cannot be used or its key set holds no keys. -The [`jose`](https://github.com/panva/jose) library verifies the tokens and keeps the key set: `decodeJwt` reads, before anything is verified, whether the token holds `aud`, which decides its form, and a token it cannot decode is judged an access token, so its refusal is the one verification gives; `jwtVerify` then checks the signature with the key of the token's `kid`, or, for a token without one, with the issuer's only key of its type, refusing it when several fit, the algorithm among `RS256`, `PS256`, `ES256` and `EdDSA`, the issuer, the audience of an access token, `exp` and `nbf` with 60 seconds of leeway and that `exp` and `sub` are present; `createRemoteJWKSet` holds a key set read at a URL, the `jwks_uri` of the metadata or the URL the operator gives, read through the bounded read above, read again for an unknown `kid` once 60 seconds have passed since the last read that succeeded, and at the first token after it is ten minutes old, `jose`'s default age, set in `src/identity-service/issuer-keys.ts`; a token whose key set cannot be read once it is ten minutes old is refused, never verified against the old one; `createLocalJWKSet` holds a key set given, which is read from nowhere and trusted until the server restarts. The runtime checks itself what is its own rule (`src/tokens/token-verification.ts`): a token at most 8 KiB before anything is decoded, the org claim of its form present unless it holds neither, refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim` or `on its o claim` otherwise, as `jose` names a claim it refuses, a `typ` of `at+jwt`, `application/at+jwt`, `JWT` or `application/jwt` or none, an `iat` at most 60 seconds ahead, a `sub` that is a non-empty string of at most 256 bytes without control characters, unpaired surrogates or noncharacters, an org, an access token's `org_id`, the `id` of a session's `o`, or the `sub` of a token that holds neither, that is an org id other than `local`, a `scope` claim, where an access token holds one, that is a string, and a session's `azp` among the origins of `pageOriginsOf`, refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its azp claim` otherwise, and then the `Origin` of the request exactly its `azp`, refused `the session was not presented by the page it was issued to` otherwise, a request without `Origin` among them; `identityServiceOf` names the audiences a request may hold, `PUBLIC_URL`, with a final `/` or not, and on an MCP endpoint that endpoint's URL. A token that holds both `aud` and `azp` is an access token, and its `azp` is not read. Each form names its org in a claim of its own, and `orgClaimPaths` of `src/tokens/token-verification.ts` is the one definition of both, since the service's metadata advertises no claim name: an access token names it as `org_id`, which the service puts in it when the person grants `user:org:read`, and a session as the `id` of its `o`, since version 2 of the service's session token, marked `v: 2`, holds the organization the person signed in to as the object `o`, with its slug, `slg`, the person's role, `rol`, their permissions, `per`, and the feature-permission map, `fpm`, beside its `id`. A session without `o` that holds an `org_id` is refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its o claim`, as an access token without `org_id` that holds an `o` is refused `on its org_id claim`, and a session whose `o` is no object or holds no `id` that is an org id other than `local` is refused `its o.id claim is not an org id other than local`; nothing else of `o` is read. A token that holds neither `org_id` nor `o` names no org, as the token of a person signed in with no organization of their own does, and acts in the org whose id is its `sub`, `subjectAsOrg` of `src/tokens/token-verification.ts`, refused `its sub claim is not an org id other than local` where the `sub` is not one; the person is then a caller of that org as of any other. `v` is not checked, since a format that named the organization elsewhere would hold no `o` and act in the org of the person's own `sub`, never in another. The tests of `src/identity-service/identity-service.test.ts` pin the claim of an access token and those of `src/identity-service/sessions.test.ts` the claim of a session. The person is `person:`, which `isSignedInPerson` tells apart from a key's id and from `local`, since neither holds a colon, in that org alone, over every playbook, with `allPermissions` for a session, whose `scope` is not read, and for an access token the permissions among the scopes its `scope` claim names, split at each space, in the order of `allPermissions`; any other scope, `user:org:read` among them, grants none, so an access token that grants none of the four, or holds no `scope`, is a person of the org with no permission (`signedInPersonOf` of `src/access/principal.ts`). No org is taken from anything else the token holds, the `org_id` of a session and the `o` of an access token among it, which only refuse a token that holds them without the claim of its form, and `user:org:read` is not checked: `org_id` is. The security guide of the documentation sets out every rule. +The [`jose`](https://github.com/panva/jose) library verifies the tokens and keeps the key set: `decodeJwt` reads, before anything is verified, whether the token holds `aud`, which decides its form, and a token it cannot decode is judged an access token, so its refusal is the one verification gives; `jwtVerify` then checks the signature with the key of the token's `kid`, or, for a token without one, with the issuer's only key of its type, refusing it when several fit, the algorithm among `RS256`, `PS256`, `ES256` and `EdDSA`, the issuer, the audience of an access token, `exp` and `nbf` with 60 seconds of leeway and that `exp`, `sub` and `name` are present; `createRemoteJWKSet` holds a key set read at a URL, the `jwks_uri` of the metadata or the URL the operator gives, read through the bounded read above, read again for an unknown `kid` once 60 seconds have passed since the last read that succeeded, and at the first token after it is ten minutes old, `jose`'s default age, set in `src/identity-service/issuer-keys.ts`; a token whose key set cannot be read once it is ten minutes old is refused, never verified against the old one; `createLocalJWKSet` holds a key set given, which is read from nowhere and trusted until the server restarts. The runtime checks itself what is its own rule (`src/tokens/token-verification.ts`): a token at most 8 KiB before anything is decoded, the org claim of its form present unless it holds neither, refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim` or `on its o claim` otherwise, as `jose` names a claim it refuses, a `typ` of `at+jwt`, `application/at+jwt`, `JWT` or `application/jwt` or none, an `iat` at most 60 seconds ahead, a `sub` that is a non-empty string of at most 256 bytes without control characters, unpaired surrogates or noncharacters, a `name` under the same rule, refused `its name is not a non-empty string of at most 256 bytes without control characters, unpaired surrogates or noncharacters` otherwise, as a `sub` is in its own words, an org, an access token's `org_id`, the `id` of a session's `o`, or the `sub` of a token that holds neither, that is an org id other than `local`, a `scope` claim, where an access token holds one, that is a string, and a session's `azp` among the origins of `pageOriginsOf`, refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its azp claim` otherwise, and then the `Origin` of the request exactly its `azp`, refused `the session was not presented by the page it was issued to` otherwise, a request without `Origin` among them; `identityServiceOf` names the audiences a request may hold, `PUBLIC_URL`, with a final `/` or not, and on an MCP endpoint that endpoint's URL. A token that holds both `aud` and `azp` is an access token, and its `azp` is not read. Each form names its org in a claim of its own, and `orgClaimPaths` of `src/tokens/token-verification.ts` is the one definition of both, since the service's metadata advertises no claim name: an access token names it as `org_id`, which the service puts in it when the person grants `user:org:read`, and a session as the `id` of its `o`, since version 2 of the service's session token, marked `v: 2`, holds the organization the person signed in to as the object `o`, with its slug, `slg`, the person's role, `rol`, their permissions, `per`, and the feature-permission map, `fpm`, beside its `id`. A session without `o` that holds an `org_id` is refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its o claim`, as an access token without `org_id` that holds an `o` is refused `on its org_id claim`, and a session whose `o` is no object or holds no `id` that is an org id other than `local` is refused `its o.id claim is not an org id other than local`; nothing else of `o` is read. A token that holds neither `org_id` nor `o` names no org, as the token of a person signed in with no organization of their own does, and acts in the org whose id is its `sub`, `subjectAsOrg` of `src/tokens/token-verification.ts`, refused `its sub claim is not an org id other than local` where the `sub` is not one; the person is then a caller of that org as of any other. `v` is not checked, since a format that named the organization elsewhere would hold no `o` and act in the org of the person's own `sub`, never in another. The tests of `src/identity-service/identity-service.test.ts` pin the claim of an access token and those of `src/identity-service/sessions.test.ts` the claim of a session. The person is `person:`, which `isSignedInPerson` tells apart from a key's id and from `local`, since neither holds a colon, named as the token's `name` claim gives it, in that org alone, over every playbook, with `allPermissions` for a session, whose `scope` is not read, and for an access token the permissions among the scopes its `scope` claim names, split at each space, in the order of `allPermissions`; any other scope, `user:org:read` among them, grants none, so an access token that grants none of the four, or holds no `scope`, is a person of the org with no permission (`signedInPersonOf` of `src/access/principal.ts`). No org is taken from anything else the token holds, the `org_id` of a session and the `o` of an access token among it, which only refuse a token that holds them without the claim of its form, and `user:org:read` is not checked: `org_id` is. The security guide of the documentation sets out every rule. -`fakeIssuer` of `@on.auto/identity/testing` is an issuer for tests: it answers its metadata, its key set and a token endpoint for the client credentials grant, whose token's `scope` claim holds the scopes the request asked for, signs a token with `jose` and a key of each algorithm, issues the session of a page, `session(page, claims)`, in the shape of version 2 of the service's session token: `iss`, `sub`, a `sid`, the page as `azp`, `iat`, `nbf`, an `exp` a minute later, `v: 2` and as `o` the organization `acme` with its `id`, `slg`, `rol`, `per` and `fpm`, under the `typ` `JWT` and with no `aud`, each claim a test gives taking the place of its own, `o` whole, so `{ o: undefined }` issues a session without one, and turns its keys over; `signedTokens` and `principalIn` help tests sign a token, which holds every sign-in scope unless a test gives it a `scope` of its own, or a session, against an identity service that allows `testAllowedOrigin` beside the Studio host, `hostedStudio`, or beside the Studio host and `testPublicUrl` when given `servedStudio`, and read the person it admits. +`fakeIssuer` of `@on.auto/identity/testing` is an issuer for tests: it answers its metadata, its key set and a token endpoint for the client credentials grant, whose token's `scope` claim holds the scopes the request asked for and whose `name` is the client's id unless the client's claims give another, signs a token with `jose` and a key of each algorithm, issues the session of a page, `session(page, claims)`, in the shape of version 2 of the service's session token: `iss`, `sub`, the person's `name`, `Ada Lovelace`, a `sid`, the page as `azp`, `iat`, `nbf`, an `exp` a minute later, `v: 2` and as `o` the organization `acme` with its `id`, `slg`, `rol`, `per` and `fpm`, under the `typ` `JWT` and with no `aud`, each claim a test gives taking the place of its own, `o` whole, so `{ o: undefined }` issues a session without one, and turns its keys over; `signedTokens` and `principalIn` help tests sign a token, which holds every sign-in scope unless a test gives it a `scope` of its own and names the person `Ada Lovelace`, or a session, against an identity service that allows `testAllowedOrigin` beside the Studio host, `hostedStudio`, or beside the Studio host and `testPublicUrl` when given `servedStudio`, and read the person it admits; `localDeveloper` is the caller of local mode as the computer that runs the tests names it, for a test that reads what it recorded. ## Measurement diff --git a/packages/identity/measure.ts b/packages/identity/measure.ts index 6deec0824..c2a5eface 100644 --- a/packages/identity/measure.ts +++ b/packages/identity/measure.ts @@ -62,7 +62,15 @@ async function verificationsBy( } const seconds = Math.floor(Date.now() / 1000); const token = await fake.sign( - { iss: fake.issuer, sub: 'ada', aud: publicUrl, iat: seconds, exp: seconds + 3600, org_id: 'acme' }, + { + iss: fake.issuer, + sub: 'ada', + name: 'Ada Lovelace', + aud: publicUrl, + iat: seconds, + exp: seconds + 3600, + org_id: 'acme', + }, { signer }, ); write( diff --git a/packages/identity/src/access/authenticator.test.ts b/packages/identity/src/access/authenticator.test.ts index 735c9b129..b395802f9 100644 --- a/packages/identity/src/access/authenticator.test.ts +++ b/packages/identity/src/access/authenticator.test.ts @@ -1,3 +1,5 @@ +import { userInfo } from 'node:os'; + import { describe, expect, it } from 'vitest'; import { @@ -35,10 +37,12 @@ const anyPath = '/v1/orgs/acme/playbooks'; const unexplained = { because: undefined }; -const adaOfAcme = { id: 'person:ada', org: 'acme', permissions: [], playbooks: [] }; +const adaOfAcme = { id: 'person:ada', name: 'Ada Lovelace', org: 'acme', permissions: [], playbooks: [] }; const signedInAda = { own: adaOfAcme, callerIn: () => adaOfAcme }; +const adasAccount = { username: 'ada' }; + const tokensSeen: string[] = []; const identityService: IdentityService = { @@ -55,11 +59,12 @@ describe('authenticatorFor on a loopback address with neither sign-in nor API ke }); it('admits every request in local mode as the local developer of whichever org it names', async () => { - const authenticator = authenticatorFor({ host: '127.0.0.1', apiKeys: undefined }); + const authenticator = authenticatorFor({ host: '127.0.0.1', apiKeys: undefined, localAccount: () => adasAccount }); const developer = principalIn(await authenticator.authenticate(undefined, anyPath)); expect(developer?.callerIn('globex')).toEqual({ id: 'local', + name: 'ada', org: 'globex', permissions: ['org:read', 'org:write', 'playbook:read', 'playbook:write'], playbooks: '*', @@ -67,6 +72,28 @@ describe('authenticatorFor on a loopback address with neither sign-in nor API ke expect(authenticator.resource).toBeUndefined(); }); + it('names the local developer by the account the server runs as on this computer', async () => { + const developer = principalIn( + await authenticatorFor({ host: '127.0.0.1', apiKeys: undefined }).authenticate(undefined, anyPath), + ); + + expect(developer?.own.name).toBe(userInfo().username); + }); + + it.each([ + [ + 'names no account', + () => { + throw new Error('ENOENT: no such file or directory, uv_os_get_passwd'); + }, + ], + ['names an account without a name', () => ({ username: '' })], + ])('names the local developer by its id where the computer %s', async (_case, localAccount) => { + const authenticator = authenticatorFor({ host: '127.0.0.1', apiKeys: undefined, localAccount }); + + expect(principalIn(await authenticator.authenticate(undefined, anyPath))?.own.name).toBe('local'); + }); + it('gives the local developer the org local as its own', async () => { const authenticator = authenticatorFor({ host: '127.0.0.1', apiKeys: undefined }); const developer = principalIn(await authenticator.authenticate('a.token.of-ada', anyPath)); @@ -126,12 +153,13 @@ describe('authenticatorFor with API keys', () => { expect(await authenticator.authenticate(undefined, anyPath)).toEqual(unexplained); }); - it('admits a configured key as a caller of its own org, and of no other', async () => { + it('admits a configured key as a caller of its own org, named by its id, and of no other', async () => { const authenticator = authenticatorFor({ host: '0.0.0.0', apiKeys: keys }); const holder = principalIn(await authenticator.authenticate(reader.key, anyPath)); expect(holder?.callerIn('acme')).toEqual({ id: 'acme-reader', + name: 'acme-reader', org: 'acme', permissions: ['playbook:read'], playbooks: ['alpha'], diff --git a/packages/identity/src/access/authenticator.ts b/packages/identity/src/access/authenticator.ts index 8e4eb730b..f341e912e 100644 --- a/packages/identity/src/access/authenticator.ts +++ b/packages/identity/src/access/authenticator.ts @@ -3,7 +3,7 @@ import { configuredApiKeys, type ApiKeySource } from '../keys/api-key-source.ts' import { keyPrefix, type ApiKey } from '../keys/api-key.ts'; import { verifyKey } from '../keys/verify-key.ts'; import { accessModeOf, type AccessMode, type DeclaredAccessMode } from './access-mode.ts'; -import { localDeveloper, principalOf, unexplained, type Authentication } from './principal.ts'; +import { localDeveloperOf, principalOf, unexplained, type Authentication, type LocalAccount } from './principal.ts'; import { ReachableWithoutCredentialsError } from './reachable-without-credentials-error.ts'; export interface Authenticator { @@ -22,6 +22,7 @@ export interface AccessSettings { readonly apiKeySource?: ApiKeySource | undefined; readonly identityService?: IdentityService | undefined; readonly accessMode?: DeclaredAccessMode | undefined; + readonly localAccount?: LocalAccount | undefined; } async function keyHolderOf(source: ApiKeySource, presented: string | undefined): Promise { @@ -36,11 +37,10 @@ function credentialHolderOf(source: ApiKeySource, identityService?: IdentityServ : keyHolderOf(source, presented); } -const localAccess: Authenticator = { - mode: 'local', - resource: undefined, - authenticate: () => Promise.resolve(localDeveloper), -}; +function localAccessOf(account: LocalAccount | undefined): Authenticator { + const localDeveloper = localDeveloperOf(account); + return { mode: 'local', resource: undefined, authenticate: () => Promise.resolve(localDeveloper) }; +} const closedAccess: Authenticator = { mode: 'closed', @@ -56,9 +56,12 @@ function credentialAccessOf(source: ApiKeySource, identityService: IdentityServi }; } -function accessOf(mode: AccessMode, { apiKeys, apiKeySource, identityService }: AccessSettings): Authenticator { +function accessOf( + mode: AccessMode, + { apiKeys, apiKeySource, identityService, localAccount }: AccessSettings, +): Authenticator { if (mode === 'local') { - return localAccess; + return localAccessOf(localAccount); } return mode === 'closed' ? closedAccess diff --git a/packages/identity/src/access/principal.ts b/packages/identity/src/access/principal.ts index 6a870cd86..fc02f10c6 100644 --- a/packages/identity/src/access/principal.ts +++ b/packages/identity/src/access/principal.ts @@ -1,4 +1,7 @@ +import { userInfo } from 'node:os'; + import { allPermissions, type CallerIdentity, type Permission } from '@on.auto/operations'; +import { Result } from 'effect'; import type { ApiKey } from '../keys/api-key.ts'; import { localDeveloperId, localOrg } from './local-org.ts'; @@ -16,36 +19,54 @@ export type Authentication = Principal | Refusal; export interface VerifiedPerson { readonly subject: string; + readonly name: string; readonly org: string; readonly permissions: readonly Permission[]; } +export type LocalAccount = () => { readonly username: string }; + export function isRefusal(authentication: Authentication): authentication is Refusal { return 'because' in authentication; } export const unexplained: Refusal = { because: undefined }; -function developerIn(org: string): CallerIdentity { - return { id: localDeveloperId, org, permissions: allPermissions, playbooks: '*' }; +function nameOfThe(account: LocalAccount): string { + const named = Result.try(() => account().username); + return Result.isSuccess(named) && named.success !== '' ? named.success : localDeveloperId; } -export const localDeveloper: Principal = { own: developerIn(localOrg), callerIn: developerIn }; +export function localDeveloperOf(account: LocalAccount = userInfo): Principal { + const name = nameOfThe(account); + const developerIn = (org: string): CallerIdentity => ({ + id: localDeveloperId, + name, + org, + permissions: allPermissions, + playbooks: '*', + }); + return { own: developerIn(localOrg), callerIn: developerIn }; +} function principalOfCaller(caller: CallerIdentity): Principal { return { own: caller, callerIn: (asked) => (asked === caller.org ? caller : undefined) }; } export function principalOf({ id, org, permissions, playbooks }: ApiKey): Principal { - return principalOfCaller({ id, org, permissions, playbooks }); + return principalOfCaller({ id, name: id, org, permissions, playbooks }); } const personPrefix = 'person:'; -export function signedInPersonOf({ subject, org, permissions }: VerifiedPerson): Principal { - return principalOfCaller({ id: `${personPrefix}${subject}`, org, permissions, playbooks: '*' }); +export function signedInPersonOf({ subject, name, org, permissions }: VerifiedPerson): Principal { + return principalOfCaller({ id: `${personPrefix}${subject}`, name, org, permissions, playbooks: '*' }); } export function isSignedInPerson({ id }: Pick): boolean { return id.startsWith(personPrefix); } + +export function isKeyHolder({ id }: Pick): boolean { + return id !== localDeveloperId && !id.includes(':'); +} diff --git a/packages/identity/src/access/signed-in-person.test.ts b/packages/identity/src/access/signed-in-person.test.ts index 9a211954a..f591b6f24 100644 --- a/packages/identity/src/access/signed-in-person.test.ts +++ b/packages/identity/src/access/signed-in-person.test.ts @@ -1,6 +1,7 @@ +import { playbookCallerOf } from '@on.auto/operations'; import { describe, expect, it } from 'vitest'; -import { authenticatorFor, createApiKey, isSignedInPerson } from '../index.ts'; +import { authenticatorFor, createApiKey, isKeyHolder, isSignedInPerson } from '../index.ts'; import { fakeIssuer, principalIn, signedTokens } from '../testing/index.ts'; const admin = createApiKey({ id: 'acme-admin', org: 'acme', permissions: ['org:write'], playbooks: '*' }); @@ -21,3 +22,11 @@ describe('a signed-in person', () => { expect(ids.map((id = '') => isSignedInPerson({ id }))).toEqual([true, false, false]); }); }); + +describe('a key holder', () => { + it('is the caller of a key, and neither a signed-in person, the local developer nor a playbook acting as itself', () => { + const ids = ['acme-admin', 'person:ada', 'local', playbookCallerOf({ org: 'acme', playbook: 'alpha' }).id]; + + expect(ids.map((id) => isKeyHolder({ id }))).toEqual([true, false, false, false]); + }); +}); diff --git a/packages/identity/src/identity-service/identity-service.test.ts b/packages/identity/src/identity-service/identity-service.test.ts index 3ebf41096..abfab94fc 100644 --- a/packages/identity/src/identity-service/identity-service.test.ts +++ b/packages/identity/src/identity-service/identity-service.test.ts @@ -75,6 +75,7 @@ describe('the person a token names', () => { expect(person).toMatchObject({ own: { id: 'person:user|ada/ü 1', + name: 'Ada Lovelace', org: 'globex', permissions: ['org:read', 'org:write', 'playbook:read', 'playbook:write'], playbooks: '*', @@ -82,13 +83,6 @@ describe('the person a token names', () => { }); expect(['globex', 'acme'].map((org) => principalIn(person)?.callerIn(org)?.org)).toEqual(['globex', undefined]); }); - - it('is named by a subject of 256 bytes', async () => { - const { token, callerOf } = await signedTokens(fakeIssuer('https://login.example'), atStart); - const subjectOf256Bytes = `${'ü'.repeat(127)}ab`; - - expect(await callerOf(await token({ sub: subjectOf256Bytes }))).toBe(`person:${subjectOf256Bytes}`); - }); }); const readScopes = ['org:read', 'playbook:read']; @@ -117,7 +111,13 @@ describe('the permissions of the person a token names', () => { const { identityService, token } = await signedTokens(fakeIssuer('https://login.example'), atStart); const person = principalIn(await identityService.personOf(await token({ scope: undefined }), '/mcp')); - expect(person?.callerIn('acme')).toEqual({ id: 'person:ada', org: 'acme', permissions: [], playbooks: '*' }); + expect(person?.callerIn('acme')).toEqual({ + id: 'person:ada', + name: 'Ada Lovelace', + org: 'acme', + permissions: [], + playbooks: '*', + }); }); it('are the four for a token that holds them without user:org:read, since org_id is what binds the org', async () => { @@ -155,16 +155,7 @@ const refusedClaims: ReadonlyArray = [ ['an iat beyond the leeway', { iat: seconds + 61 }, 'its iat is more than 60 seconds ahead'], ]; -const subjectRefusal = - 'its sub is not a non-empty string of at most 256 bytes without control characters, unpaired surrogates or noncharacters'; - const refusedPersons: ReadonlyArray = [ - ['no sub', { sub: undefined }, 'ERR_JWT_CLAIM_VALIDATION_FAILED on its sub claim'], - ['an empty sub', { sub: '' }, subjectRefusal], - ['a sub that is not a string', { sub: 7 }, subjectRefusal], - ['a sub over 256 bytes', { sub: 'ü'.repeat(129) }, subjectRefusal], - ['a sub with a control character', { sub: 'ada\u0000' }, subjectRefusal], - ['a sub with a noncharacter', { sub: 'ada\uFFFF' }, subjectRefusal], ['an org_id that is no org id', { org_id: 'ac me' }, 'its org_id claim is not an org id other than local'], ['the org of local mode', { org_id: 'local' }, 'its org_id claim is not an org id other than local'], ['an org_id that is not a string', { org_id: 7 }, 'its org_id claim is not an org id other than local'], @@ -207,6 +198,7 @@ describe('the person a token that names no org names', () => { expect(person?.own).toEqual({ id: 'person:user_2ada', + name: 'Ada Lovelace', org: 'user_2ada', permissions: ['playbook:read'], playbooks: '*', diff --git a/packages/identity/src/identity-service/issuer-key-sources.test.ts b/packages/identity/src/identity-service/issuer-key-sources.test.ts index d91ab57de..8b23147b5 100644 --- a/packages/identity/src/identity-service/issuer-key-sources.test.ts +++ b/packages/identity/src/identity-service/issuer-key-sources.test.ts @@ -70,7 +70,14 @@ function serviceOf(fake: FakeIssuer, fetch: IssuerFetch, issuerKeys?: IssuerKeyS function signedBy(fake: FakeIssuer): Promise { const seconds = Math.floor(startedAt / 1000); - return fake.sign({ iss: fake.issuer, sub: 'ada', aud: testPublicUrl, exp: seconds + 3600, org_id: 'acme' }); + return fake.sign({ + iss: fake.issuer, + sub: 'ada', + name: 'Ada Lovelace', + aud: testPublicUrl, + exp: seconds + 3600, + org_id: 'acme', + }); } async function callerOf(identityService: IdentityService, token: string): Promise { diff --git a/packages/identity/src/identity-service/key-choice.test.ts b/packages/identity/src/identity-service/key-choice.test.ts index 7432af8a1..0f360df91 100644 --- a/packages/identity/src/identity-service/key-choice.test.ts +++ b/packages/identity/src/identity-service/key-choice.test.ts @@ -10,7 +10,14 @@ const issuer = 'https://keys.example'; const seconds = Math.floor(Date.now() / 1000); -const claims = { iss: issuer, sub: 'ada', aud: testPublicUrl, exp: seconds + 300, org_id: 'acme' }; +const claims = { + iss: issuer, + sub: 'ada', + name: 'Ada Lovelace', + aud: testPublicUrl, + exp: seconds + 300, + org_id: 'acme', +}; const first = generateKeyPairSync('rsa', { modulusLength: 2048 }); diff --git a/packages/identity/src/identity-service/person-names.test.ts b/packages/identity/src/identity-service/person-names.test.ts new file mode 100644 index 000000000..bb441002c --- /dev/null +++ b/packages/identity/src/identity-service/person-names.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { fakeIssuer, principalIn, signedTokens, type Claims } from '../testing/index.ts'; + +const atStart = { now: () => Date.parse('2026-10-13T09:00:00Z') }; + +const ofTheBytesOfAReference = `${'ü'.repeat(127)}ab`; + +describe('the person a token names, by id and by name', () => { + it('is named by a subject of 256 bytes', async () => { + const { token, callerOf } = await signedTokens(fakeIssuer('https://login.example'), atStart); + + expect(await callerOf(await token({ sub: ofTheBytesOfAReference }))).toBe(`person:${ofTheBytesOfAReference}`); + }); + + it('is called by the name its name claim gives, of up to 256 bytes', async () => { + const { identityService, token } = await signedTokens(fakeIssuer('https://login.example'), atStart); + const names = await Promise.all( + ['Ada Lovelace', 'ada', ofTheBytesOfAReference].map( + async (name) => principalIn(await identityService.personOf(await token({ name }), '/mcp'))?.own.name, + ), + ); + + expect(names).toEqual(['Ada Lovelace', 'ada', ofTheBytesOfAReference]); + }); +}); + +function refusalOf(claim: string): string { + return `its ${claim} is not a non-empty string of at most 256 bytes without control characters, unpaired surrogates or noncharacters`; +} + +const refusedNames: ReadonlyArray = [ + ['no sub', { sub: undefined }, 'ERR_JWT_CLAIM_VALIDATION_FAILED on its sub claim'], + ['an empty sub', { sub: '' }, refusalOf('sub')], + ['a sub that is not a string', { sub: 7 }, refusalOf('sub')], + ['a sub over 256 bytes', { sub: 'ü'.repeat(129) }, refusalOf('sub')], + ['a sub with a control character', { sub: 'ada\u0000' }, refusalOf('sub')], + ['a sub with a noncharacter', { sub: 'ada\uFFFF' }, refusalOf('sub')], + ['no name', { name: undefined }, 'ERR_JWT_CLAIM_VALIDATION_FAILED on its name claim'], + ['an empty name', { name: '' }, refusalOf('name')], + ['a name that is not a string', { name: { given: 'Ada' } }, refusalOf('name')], + ['a name over 256 bytes', { name: 'ü'.repeat(129) }, refusalOf('name')], + ['a name with a control character', { name: 'Ada\nLovelace' }, refusalOf('name')], + ['a name with a noncharacter', { name: 'Ada\uFFFF' }, refusalOf('name')], +]; + +describe('a token whose person the runtime cannot name', () => { + it.each(refusedNames)('is refused for %s, naming the claim', async (_case, claims, because) => { + const { identityService, token } = await signedTokens(fakeIssuer('https://login.example'), atStart); + + expect(await identityService.personOf(await token(claims), '/mcp')).toEqual({ because }); + }); +}); diff --git a/packages/identity/src/identity-service/sessions.test.ts b/packages/identity/src/identity-service/sessions.test.ts index c881a04fb..edb6d186b 100644 --- a/packages/identity/src/identity-service/sessions.test.ts +++ b/packages/identity/src/identity-service/sessions.test.ts @@ -20,6 +20,8 @@ const atStart = { now: () => startedAt }; const everyPermissionOverEveryPlaybook = { permissions: allPermissions, playbooks: '*' }; +const ada = { id: 'person:ada', name: 'Ada Lovelace' }; + const pageRefusal = 'ERR_JWT_CLAIM_VALIDATION_FAILED on its azp claim'; const noOrgClaim = 'ERR_JWT_CLAIM_VALIDATION_FAILED on its o claim'; @@ -43,7 +45,7 @@ describe('a session the identity service gave a page', () => { await identityService.personOf(await session(page, { o: { id: 'globex' } }), '', page), ); - expect(person?.own).toEqual({ id: 'person:ada', org: 'globex', ...everyPermissionOverEveryPlaybook }); + expect(person?.own).toEqual({ ...ada, org: 'globex', ...everyPermissionOverEveryPlaybook }); expect(person?.callerIn('acme')).toBeUndefined(); }, ); @@ -80,7 +82,7 @@ describe('a session read for its o claim', () => { await identityService.personOf(await session(defaultStudioOrigin, { o }), '', defaultStudioOrigin), ); - expect(person?.own).toEqual({ id: 'person:ada', org: 'acme', ...everyPermissionOverEveryPlaybook }); + expect(person?.own).toEqual({ ...ada, org: 'acme', ...everyPermissionOverEveryPlaybook }); }); it('is the person of the org its o.id names, never of an org_id it holds', async () => { @@ -102,7 +104,12 @@ describe('a session that names no org', () => { await identityService.personOf(await session(defaultStudioOrigin, claims), '', defaultStudioOrigin), ); - expect(person?.own).toEqual({ id: 'person:user_2ada', org: 'user_2ada', ...everyPermissionOverEveryPlaybook }); + expect(person?.own).toEqual({ + id: 'person:user_2ada', + name: 'Ada Lovelace', + org: 'user_2ada', + ...everyPermissionOverEveryPlaybook, + }); expect(person?.callerIn('acme')).toBeUndefined(); }); @@ -123,7 +130,7 @@ describe('a session given to the page the runtime serves at its own origin', () const { identityService, session } = await signedTokens(fakeIssuer('https://login.example'), atStart, servedStudio); const person = principalIn(await identityService.personOf(await session(testPublicUrl), '', testPublicUrl)); - expect(person?.own).toEqual({ id: 'person:ada', org: 'acme', ...everyPermissionOverEveryPlaybook }); + expect(person?.own).toEqual({ ...ada, org: 'acme', ...everyPermissionOverEveryPlaybook }); }); it('is refused for its azp where the runtime serves no Studio', async () => { @@ -173,6 +180,13 @@ const refusedSessions: ReadonlyArray ['an o whose id is no org id', defaultStudioOrigin, { o: { id: 'ac me' } }, orgRefusal], ['an o whose id is not a string', defaultStudioOrigin, { o: { id: 7 } }, orgRefusal], ['no sub', defaultStudioOrigin, { sub: undefined }, 'ERR_JWT_CLAIM_VALIDATION_FAILED on its sub claim'], + ['no name', defaultStudioOrigin, { name: undefined }, 'ERR_JWT_CLAIM_VALIDATION_FAILED on its name claim'], + [ + 'an empty name', + defaultStudioOrigin, + { name: '' }, + 'its name is not a non-empty string of at most 256 bytes without control characters, unpaired surrogates or noncharacters', + ], [ 'a wrong iss', defaultStudioOrigin, diff --git a/packages/identity/src/index.ts b/packages/identity/src/index.ts index af95f4949..e33442fc2 100644 --- a/packages/identity/src/index.ts +++ b/packages/identity/src/index.ts @@ -14,7 +14,14 @@ export { studioLinkOf, type StudioSettings, } from './access/studio.ts'; -export { isRefusal, isSignedInPerson, type Authentication, type Principal, type Refusal } from './access/principal.ts'; +export { + isKeyHolder, + isRefusal, + isSignedInPerson, + type Authentication, + type Principal, + type Refusal, +} from './access/principal.ts'; export { runKeyCommand, type Console as KeyCommandConsole } from './key-command/run-key-command.ts'; export { UnusableIdentityServiceError } from './identity-service/unusable-identity-service-error.ts'; export { diff --git a/packages/identity/src/keys/api-key.ts b/packages/identity/src/keys/api-key.ts index 673c291d1..96cc08571 100644 --- a/packages/identity/src/keys/api-key.ts +++ b/packages/identity/src/keys/api-key.ts @@ -13,9 +13,10 @@ const KeyIdSchema = Schema.String.check( const DigestSchema = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/u)); export const KeyGrantSchema = Schema.Struct({ - ...CallerIdentitySchema.fields, id: KeyIdSchema, org: CredentialOrgSchema, + permissions: CallerIdentitySchema.fields.permissions, + playbooks: CallerIdentitySchema.fields.playbooks, }); export type KeyGrant = typeof KeyGrantSchema.Type; diff --git a/packages/identity/src/testing/fake-issuer.test.ts b/packages/identity/src/testing/fake-issuer.test.ts index 3824d6ede..c1c178c16 100644 --- a/packages/identity/src/testing/fake-issuer.test.ts +++ b/packages/identity/src/testing/fake-issuer.test.ts @@ -21,7 +21,7 @@ function tokenRequest(credentials: string, form: Readonly } describe('the fake issuer', () => { - it('mints a token for a client by its credentials, for the resource and the scopes asked, that the runtime verifies', async () => { + it('mints a token for a client by its credentials, named by the client, for the resource and the scopes asked, that the runtime verifies', async () => { const fake = fakeIssuer('https://login.example', { clients }); const identityService = await identityServiceOf( { issuer: fake.issuer, publicUrl: 'https://runtime.example', studio: hostedStudio, allowedOrigins: [] }, @@ -39,6 +39,7 @@ describe('the fake issuer', () => { expect(principalIn(await identityService.personOf(token, '/mcp'))?.callerIn('acme')).toMatchObject({ id: 'person:ada', + name: 'ada-agent', permissions: ['org:read'], }); }); @@ -82,7 +83,7 @@ function segmentOf(token: string, index: number): unknown { } describe('the fake issuer giving a page its session', () => { - it('issues the session of version 2 the identity service sends, of a minute, to the page as azp, its org as o', async () => { + it('issues the session of version 2 the identity service sends, of a minute, to the page as azp, its org as o, naming the person', async () => { const session = await fakeIssuer('https://login.example').session('https://studio.example'); const claims = segmentOf(session, 1); const { iat, nbf, exp } = timesOf(claims); @@ -92,6 +93,7 @@ describe('the fake issuer giving a page its session', () => { { iss: 'https://login.example', sub: 'ada', + name: 'Ada Lovelace', sid: 'session-1', azp: 'https://studio.example', iat, diff --git a/packages/identity/src/testing/fake-issuer.ts b/packages/identity/src/testing/fake-issuer.ts index f60544151..6cbfdb1a9 100644 --- a/packages/identity/src/testing/fake-issuer.ts +++ b/packages/identity/src/testing/fake-issuer.ts @@ -109,6 +109,7 @@ function tokenEndpoint( const minted = await signed({ iss: issuer, sub: client, + name: client, aud: form.get('resource') ?? issuer, iat: now, exp: now + lifetimeSeconds, @@ -135,6 +136,7 @@ function sessionsOf(issuer: string, signed: Sign): FakeIssuer['session'] { { iss: issuer, sub: 'ada', + name: 'Ada Lovelace', sid: `session-${issued}`, azp: page, iat: now, diff --git a/packages/identity/src/testing/index.ts b/packages/identity/src/testing/index.ts index ff197904a..f32e7f612 100644 --- a/packages/identity/src/testing/index.ts +++ b/packages/identity/src/testing/index.ts @@ -7,6 +7,6 @@ export { type FakeSigner, type Signing, } from './fake-issuer.ts'; -export { principalIn } from './principals.ts'; +export { localDeveloper, principalIn } from './principals.ts'; export { signedTokens, testAllowedOrigin, testPublicUrl, type SignedTokens } from './signed-tokens.ts'; export { hostedStudio, servedStudio } from './studios.ts'; diff --git a/packages/identity/src/testing/principals.ts b/packages/identity/src/testing/principals.ts index cc135ecd5..50c5b1599 100644 --- a/packages/identity/src/testing/principals.ts +++ b/packages/identity/src/testing/principals.ts @@ -1,4 +1,8 @@ -import { isRefusal, type Authentication, type Principal } from '../access/principal.ts'; +import type { CallerIdentity } from '@on.auto/operations'; + +import { isRefusal, localDeveloperOf, type Authentication, type Principal } from '../access/principal.ts'; + +export const localDeveloper: CallerIdentity = localDeveloperOf().own; export function principalIn(authentication: Authentication): Principal | undefined { return isRefusal(authentication) ? undefined : authentication; diff --git a/packages/identity/src/testing/signed-tokens.ts b/packages/identity/src/testing/signed-tokens.ts index c14a819a2..d558583f9 100644 --- a/packages/identity/src/testing/signed-tokens.ts +++ b/packages/identity/src/testing/signed-tokens.ts @@ -38,6 +38,7 @@ export async function signedTokens( { iss: fake.issuer, sub: 'ada', + name: 'Ada Lovelace', aud: testPublicUrl, iat: seconds, exp: seconds + 300, diff --git a/packages/identity/src/tokens/token-verification.ts b/packages/identity/src/tokens/token-verification.ts index aea5fead2..e9c01a34b 100644 --- a/packages/identity/src/tokens/token-verification.ts +++ b/packages/identity/src/tokens/token-verification.ts @@ -19,6 +19,8 @@ const scopeClaim = 'scope'; const pageClaim = 'azp'; +const nameClaim = 'name'; + const leewaySeconds = 60; const accessTokenTypes: ReadonlySet = new Set(['at+jwt', 'application/at+jwt', 'jwt', 'application/jwt']); @@ -89,7 +91,7 @@ function optionsOf({ issuer, audiences, now }: Expected, form: Form): JWTVerifyO ...(form === 'access token' ? { audience: [...audiences] } : {}), algorithms: signingAlgorithms, clockTolerance: leewaySeconds, - requiredClaims: ['exp', 'sub'], + requiredClaims: ['exp', 'sub', nameClaim], currentDate: new Date(now), }; } @@ -117,8 +119,17 @@ function pageRefusal({ [pageClaim]: page }: Payload, form: Form, expected: Expec return page === expected.requestOrigin ? undefined : presentedByAnotherPage; } -function isSubject(sub: unknown): sub is string { - return Predicate.isString(sub) && sub !== '' && bytesOfReference(sub) <= mostReferenceBytes; +function isNamingClaim(claim: unknown): claim is string { + return ( + Predicate.isString(claim) && + claim !== '' && + bytesOfReference(claim) <= mostReferenceBytes && + holdsNoForbiddenCharacter(claim) + ); +} + +function notNaming(claim: string): string { + return `its ${claim} is not a non-empty string of at most ${mostReferenceBytes} bytes without ${forbiddenCharactersInWords}`; } function permissionsGrantedBy({ [scopeClaim]: scope = '' }: Payload, form: Form): string | readonly Permission[] { @@ -133,20 +144,23 @@ function permissionsGrantedBy({ [scopeClaim]: scope = '' }: Payload, form: Form) } function personIn(payload: Payload, form: Form, now: number): string | VerifiedPerson { - const { iat, sub } = payload; + const { iat, sub, [nameClaim]: name } = payload; const orgClaimPath = orgClaimPathOf(payload, form); const org = orgIn(payload, orgClaimPath); if (Predicate.isNumber(iat) && iat > now / 1000 + leewaySeconds) { return `its iat is more than ${leewaySeconds} seconds ahead`; } - if (!isSubject(sub) || !holdsNoForbiddenCharacter(sub)) { - return `its sub is not a non-empty string of at most ${mostReferenceBytes} bytes without ${forbiddenCharactersInWords}`; + if (!isNamingClaim(sub)) { + return notNaming('sub'); + } + if (!isNamingClaim(name)) { + return notNaming(nameClaim); } if (!isCredentialOrg(org)) { return `its ${orgClaimPath.join('.')} claim is not an org id other than local`; } const permissions = permissionsGrantedBy(payload, form); - return Predicate.isString(permissions) ? permissions : { subject: sub, org, permissions }; + return Predicate.isString(permissions) ? permissions : { subject: sub, name, org, permissions }; } async function verified(token: string, keySet: JWTVerifyGetKey, expected: Expected): Promise { diff --git a/packages/operations/README.md b/packages/operations/README.md index 0d0743ef0..be369bfd4 100644 --- a/packages/operations/README.md +++ b/packages/operations/README.md @@ -124,13 +124,13 @@ An org operation targets at most one playbook, and names it `playbook`. When its 7. The handler runs. 8. The output is checked against the output schema and encoded as JSON. -A caller of one org gets the same `forbidden` rejection for an existing and a missing playbook of another org. A caller's identity can be decoded with `CallerIdentitySchema`. +A caller of one org gets the same `forbidden` rejection for an existing and a missing playbook of another org. A caller's identity can be decoded with `CallerIdentitySchema`: its `id`, its `name`, the caller in words, its `org`, its `permissions` and its `playbooks`. A record names who did something by `RecordedCallerSchema`, the caller's `id` and `name` alone, which `recordedCallerOf(caller)` takes from a caller, so what a playbook, a definition, a connection or a run records of who made it says whom an agent reads, while the context of the fact keeps `by`, the id alone. A playbook request may carry a `lineage`, `{ causationId, correlationId }`: the message that caused the call and the run the call belongs to, a `depth`, the reaction depth of the run it starts (see the workflow host's reactions), a `callDepth`, the number of calls above the run it starts, a `calledBy`, the call that run answers (`CallLink`: the run id of the workflow, the call's reference and its run), a `trigger`, the trigger of a workflow that started the run (`TriggerLink`: its kind, `event`, `cron` or `every`, and its reference in the document), and `attemptDied: true` when the caller knows that the attempt it made of the run before died, as the workflow host knows of a call it performs again after the host that ran it stopped. Only callers in the same process set them, as the workflow host does when a workflow calls a function or a trigger starts a workflow; a transport never does, so no input reaches them. The playbook binding gives them to a playbook command as the `CallLineage` service, `{ lineage, depth, callDepth, calledBy, trigger, attemptDied }`, `lineage`, `calledBy` and `trigger` `null`, the depths 0 and `attemptDied` `false` when the request carried none, and the command passes the lineage to `PlaybookWriter.execute` with the events it appends. A definition sets `permittedBy` when a permission other than the one of its kind and scope lets a caller call it: `list_playbooks` of `@on.auto/playbooks`, an org query that answers only the playbooks its caller may access, is permitted by `org:read` or `playbook:read`, so a key that may only read inside some playbooks can find them. The registration carries the list as `permissions`, the permission of the kind and scope when left out; a definition permitted by no permission is refused when it is defined. A transport that lists what a caller may call, as the MCP tools do, checks the same list. -`playbookCallerOf({ org, playbook })` is the caller a playbook acts as itself, for the work no person started, such as a run a reaction starts: `playbook:`, with `playbook:read` and `playbook:write` on that playbook and no other, an id no API key can hold, since key ids take no `:`. +`playbookCallerOf({ org, playbook })` is the caller a playbook acts as itself, for the work no person started, such as a run a reaction starts: `playbook:`, named by the playbook's id, with `playbook:read` and `playbook:write` on that playbook and no other, an id no API key can hold, since key ids take no `:`. The outcome is `succeeded`, `rejected` or `failed`. The error boundary turns any defect into a `failed` outcome with an incident id. The `IncidentReporter` receives the id, the original defect and the call: the operation, the org, the playbook when there is one, and the caller's id, never the input. It has two seconds; when it fails or takes longer, the incident id is logged with `Effect.logError`, and the outcome stays `failed`. diff --git a/packages/operations/src/caller/caller.test.ts b/packages/operations/src/caller/caller.test.ts index 4065e7616..c9d0fe8fa 100644 --- a/packages/operations/src/caller/caller.test.ts +++ b/packages/operations/src/caller/caller.test.ts @@ -1,11 +1,17 @@ import { Schema } from 'effect'; import { describe, expect, expectTypeOf, it } from 'vitest'; -import { CallerIdentitySchema, playbookCallerOf, canAccessPlaybook, type CallerIdentity } from '../index.ts'; +import { CallerIdentitySchema, canAccessPlaybook, playbookCallerOf, type CallerIdentity } from '../index.ts'; const isCallerIdentity = Schema.is(CallerIdentitySchema); -const keyHolder = { id: 'build-bot', org: 'acme', permissions: ['playbook:read', 'org:read'], playbooks: ['alpha'] }; +const keyHolder = { + id: 'build-bot', + name: 'build-bot', + org: 'acme', + permissions: ['playbook:read', 'org:read'], + playbooks: ['alpha'], +}; describe('a caller identity', () => { it('decodes into the identity the dispatcher takes', () => { @@ -16,6 +22,8 @@ describe('a caller identity', () => { const malformed: ReadonlyArray = [ ['an empty id', { ...keyHolder, id: '' }], + ['no name', { id: 'build-bot', org: 'acme', permissions: [], playbooks: '*' }], + ['an empty name', { ...keyHolder, name: '' }], ['an ill-formed org', { ...keyHolder, org: 'ac/me' }], ['an unknown permission', { ...keyHolder, permissions: ['org:admin'] }], ['playbooks as a plain string', { ...keyHolder, playbooks: 'alphabet-beta' }], @@ -28,12 +36,13 @@ describe('a caller identity', () => { }); describe('the caller a playbook acts as itself', () => { - it('may read and write that playbook alone, and names the playbook in a way no key id can', () => { + it('may read and write that playbook alone, and names the playbook by its id in a way no key id can', () => { const playbook = playbookCallerOf({ org: 'acme', playbook: 'alpha' }); expect(isCallerIdentity(playbook)).toBe(true); expect(playbook).toEqual({ id: 'playbook:alpha', + name: 'alpha', org: 'acme', permissions: ['playbook:read', 'playbook:write'], playbooks: ['alpha'], diff --git a/packages/operations/src/caller/caller.ts b/packages/operations/src/caller/caller.ts index 16273124c..820084551 100644 --- a/packages/operations/src/caller/caller.ts +++ b/packages/operations/src/caller/caller.ts @@ -6,6 +6,7 @@ import { PlaybookAccessSchema, type PlaybookAccess } from './playbook-access.ts' export interface CallerIdentity { readonly id: string; + readonly name: string; readonly org: string; readonly permissions: readonly Permission[]; readonly playbooks: PlaybookAccess; @@ -13,6 +14,7 @@ export interface CallerIdentity { export const CallerIdentitySchema = Schema.Struct({ id: Schema.NonEmptyString, + name: Schema.NonEmptyString, org: OrgIdSchema, permissions: Schema.Array(PermissionSchema), playbooks: PlaybookAccessSchema, @@ -25,7 +27,13 @@ export function playbookCallerOf({ readonly org: string; readonly playbook: string; }): CallerIdentity { - return { id: `playbook:${playbook}`, org, permissions: ['playbook:read', 'playbook:write'], playbooks: [playbook] }; + return { + id: `playbook:${playbook}`, + name: playbook, + org, + permissions: ['playbook:read', 'playbook:write'], + playbooks: [playbook], + }; } export class Caller extends Context.Service()('@on.auto/operations/Caller') {} diff --git a/packages/operations/src/index.ts b/packages/operations/src/index.ts index 026915255..d4afa9e74 100644 --- a/packages/operations/src/index.ts +++ b/packages/operations/src/index.ts @@ -91,11 +91,13 @@ export { ContextSchema, DefinitionContextSchema, EmittedContextSchema, + RecordedCallerSchema, RunContextSchema, StartingTriggerSchema, bytesOfReference, checkedContext, contextOf, + recordedCallerOf, forbiddenCharactersInWords, holdsNoForbiddenCharacter, leastCount, @@ -108,6 +110,7 @@ export { type Context, type DefinitionContext, type EmittedContext, + type RecordedCaller, type RunContext, type StartingTrigger, } from './ledger/context.ts'; diff --git a/packages/operations/src/ledger/context.test.ts b/packages/operations/src/ledger/context.test.ts index 6215e09b7..6372f5ba5 100644 --- a/packages/operations/src/ledger/context.test.ts +++ b/packages/operations/src/ledger/context.test.ts @@ -7,6 +7,7 @@ import { ContextReference, DefinitionContextSchema, EmittedContextSchema, + RecordedCallerSchema, RunContextSchema, bytesOfReference, checkedContext, @@ -14,6 +15,7 @@ import { holdsNoForbiddenCharacter, leastCount, mostReferenceBytes, + recordedCallerOf, type Context, } from './context.ts'; import { factOf, recordingOf } from './decider.ts'; @@ -31,6 +33,21 @@ const ofACall: Context = { trigger: { kind: 'event', reference: '/schedule/on' }, }; +describe('the caller as the data of a fact names who did something', () => { + it('is the id and the name of the caller, and nothing it may do, where the context names the id alone', () => { + const recorded = recordedCallerOf({ + id: 'person:user_2ada', + name: 'Ada Lovelace', + org: 'acme', + permissions: ['playbook:read'], + playbooks: '*', + }); + + expect(recorded).toStrictEqual({ id: 'person:user_2ada', name: 'Ada Lovelace' }); + expect(Schema.decodeUnknownSync(RecordedCallerSchema)(recorded)).toStrictEqual(recorded); + }); +}); + describe('the context of a recorded message', () => { it('is read from its metadata without the store’s own fields', () => { expect( diff --git a/packages/operations/src/ledger/context.ts b/packages/operations/src/ledger/context.ts index 40cb82399..558fa46e7 100644 --- a/packages/operations/src/ledger/context.ts +++ b/packages/operations/src/ledger/context.ts @@ -1,5 +1,7 @@ import { Schema } from 'effect'; +import type { CallerIdentity } from '../caller/caller.ts'; + const forbiddenCharacter = /[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u; export const forbiddenCharactersInWords = 'control characters, unpaired surrogates or noncharacters'; @@ -46,6 +48,20 @@ export const StartingTriggerSchema = Schema.Struct({ kind: TriggerKindSchema, re export type StartingTrigger = typeof StartingTriggerSchema.Type; +export const RecordedCallerSchema = Schema.Struct({ + id: Schema.String.annotate({ description: 'The id of the caller' }), + name: Schema.String.annotate({ + description: + "The name of the caller: a person's name as they signed in, the id of a key, the name of the account on the computer in local mode, or the id of the playbook acting as itself", + }), +}); + +export type RecordedCaller = typeof RecordedCallerSchema.Type; + +export function recordedCallerOf({ id, name }: CallerIdentity): RecordedCaller { + return { id, name }; +} + const acted = { at: ContextText, by: ContextText }; const onTheRun = { runId: ContextText }; diff --git a/packages/operations/src/testing/callers.ts b/packages/operations/src/testing/callers.ts index d88a1ee1c..bd0ce7dca 100644 --- a/packages/operations/src/testing/callers.ts +++ b/packages/operations/src/testing/callers.ts @@ -1,14 +1,21 @@ import { allPermissions, type CallerIdentity, type Context } from '../index.ts'; -export const acmeAdmin: CallerIdentity = { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }; +export const acmeAdmin: CallerIdentity = { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: allPermissions, + playbooks: '*', +}; export const acmeAlphaReader: CallerIdentity = { id: 'acme-alpha-reader', + name: 'acme-alpha-reader', org: 'acme', permissions: ['org:read', 'playbook:read'], playbooks: ['alpha'], }; -export const globexAdmin: CallerIdentity = { ...acmeAdmin, id: 'globex-admin', org: 'globex' }; +export const globexAdmin: CallerIdentity = { ...acmeAdmin, id: 'globex-admin', name: 'globex-admin', org: 'globex' }; export const testedContext: Context = { at: '2026-10-05T09:00:00.000Z', by: 'tester' }; diff --git a/packages/playbooks/README.md b/packages/playbooks/README.md index 5d63f9716..be1211342 100644 --- a/packages/playbooks/README.md +++ b/packages/playbooks/README.md @@ -13,7 +13,7 @@ A playbook belongs to one org. | `description` | 0 to 2000 characters, stored trimmed | | `status` | `active` or `retired` | | `created_at` | When it was created | -| `created_by` | The id of the caller who created it | +| `created_by` | The caller who created it, by `id` and `name`, as the fact that creates it, `playbook_created`, holds it | | `updated_at` | When it last changed | | `retired_at` | When it was retired, on a retired playbook only | diff --git a/packages/playbooks/src/operations/create-playbook.test.ts b/packages/playbooks/src/operations/create-playbook.test.ts index 86153e1e4..9d2df52fd 100644 --- a/packages/playbooks/src/operations/create-playbook.test.ts +++ b/packages/playbooks/src/operations/create-playbook.test.ts @@ -2,7 +2,7 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { createPlaybook, retirePlaybook } from '../index.ts'; -import { acmeAdmin } from '../testing/callers.ts'; +import { acmeAdmin, adaOfAcme } from '../testing/callers.ts'; import { firstMoment, harness, toOrg } from '../testing/harness.ts'; const toAcme = toOrg('acme'); @@ -37,12 +37,20 @@ describe('create_playbook', () => { description: 'Answers sales', status: 'active', created_at: firstMoment, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: firstMoment, }, }); }); + it('names a person who created it by their id and their name', async () => { + expect(await harness().call(createPlaybook, toAcme(adaOfAcme, { playbook: 'alpha', name: 'Alpha' }))).toMatchObject( + { + output: { created_by: { id: 'person:user_2ada', name: 'Ada Lovelace' } }, + }, + ); + }); + it('records the facts of the org in its playbooks stream', async () => { const { call, ledger } = harness(); diff --git a/packages/playbooks/src/operations/create-playbook.ts b/packages/playbooks/src/operations/create-playbook.ts index ced335b4f..cf2f24370 100644 --- a/packages/playbooks/src/operations/create-playbook.ts +++ b/packages/playbooks/src/operations/create-playbook.ts @@ -1,4 +1,4 @@ -import { defineCommand, quoted } from '@on.auto/operations'; +import { Caller, defineCommand, quoted, recordedCallerOf } from '@on.auto/operations'; import { Effect, Schema } from 'effect'; import { playbookNamed, purposeOf } from '../plain-language/playbook-words.ts'; @@ -25,7 +25,9 @@ export const createPlaybook = defineCommand('org', { outputSchema: PlaybookSchema, reasons: ['conflict'], handle: ({ playbook, name, description = '' }) => - recordInRegistry({ type: 'create', playbook, name, description }).pipe(Effect.catchTag('not_found', Effect.die)), + Caller.use((caller) => + recordInRegistry({ type: 'create', playbook, name, description, created_by: recordedCallerOf(caller) }), + ).pipe(Effect.catchTag('not_found', Effect.die)), plainLanguage: { task: 'create a playbook', attempt: ({ playbook }) => `create the playbook ${quoted(playbook)}`, diff --git a/packages/playbooks/src/operations/get-playbook.test.ts b/packages/playbooks/src/operations/get-playbook.test.ts index 249e9a2a0..c7cce1171 100644 --- a/packages/playbooks/src/operations/get-playbook.test.ts +++ b/packages/playbooks/src/operations/get-playbook.test.ts @@ -37,7 +37,7 @@ describe('get_playbook', () => { description: 'Answers sales', status: 'active', created_at: firstMoment, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: firstMoment, }, }); diff --git a/packages/playbooks/src/operations/isolation.test.ts b/packages/playbooks/src/operations/isolation.test.ts index bf4028403..15604d6d5 100644 --- a/packages/playbooks/src/operations/isolation.test.ts +++ b/packages/playbooks/src/operations/isolation.test.ts @@ -19,10 +19,10 @@ describe('the playbooks of two orgs', () => { await call(retirePlaybook, toGlobex(globexAdmin, { playbook: 'alpha' })); expect(await call(getPlaybook, toAcme(acmeAdmin, { playbook: 'alpha' }))).toMatchObject({ - output: { name: 'Renamed', status: 'active', created_by: 'acme-admin' }, + output: { name: 'Renamed', status: 'active', created_by: { id: 'acme-admin', name: 'acme-admin' } }, }); expect(await call(getPlaybook, toGlobex(globexAdmin, { playbook: 'alpha' }))).toMatchObject({ - output: { name: 'Globex Alpha', status: 'retired', created_by: 'globex-admin' }, + output: { name: 'Globex Alpha', status: 'retired', created_by: { id: 'globex-admin', name: 'globex-admin' } }, }); expect(await call(listPlaybooks, toAcme(acmeAdmin))).toMatchObject({ output: { playbooks: [{ name: 'Renamed' }] }, @@ -118,11 +118,11 @@ describe('creating a playbook', () => { }); expect(await call(createPlaybook, toAcme(acmeAlphaKeeper, { playbook: 'alpha', name: 'Alpha' }))).toMatchObject({ status: 'succeeded', - output: { id: 'alpha', created_by: 'acme-alpha-keeper' }, + output: { id: 'alpha', created_by: { id: 'acme-alpha-keeper', name: 'acme-alpha-keeper' } }, }); expect(await call(createPlaybook, toAcme(acmeAdmin, { playbook: 'delta', name: 'Delta' }))).toMatchObject({ status: 'succeeded', - output: { id: 'delta', created_by: 'acme-admin' }, + output: { id: 'delta', created_by: { id: 'acme-admin', name: 'acme-admin' } }, }); }); }); diff --git a/packages/playbooks/src/operations/list-playbooks.test.ts b/packages/playbooks/src/operations/list-playbooks.test.ts index ed9b89d61..ead7aa1cc 100644 --- a/packages/playbooks/src/operations/list-playbooks.test.ts +++ b/packages/playbooks/src/operations/list-playbooks.test.ts @@ -22,7 +22,7 @@ function activePlaybook(id: string, name: string) { description: '', status: 'active', created_at: firstMoment, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: firstMoment, }; } diff --git a/packages/playbooks/src/operations/retire-playbook.test.ts b/packages/playbooks/src/operations/retire-playbook.test.ts index 5b6f365e3..87c377df2 100644 --- a/packages/playbooks/src/operations/retire-playbook.test.ts +++ b/packages/playbooks/src/operations/retire-playbook.test.ts @@ -17,7 +17,7 @@ const retiredAlpha = { description: '', status: 'retired', created_at: firstMoment, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: later, retired_at: later, }; diff --git a/packages/playbooks/src/operations/update-playbook.test.ts b/packages/playbooks/src/operations/update-playbook.test.ts index 44d233cb5..1d1b72980 100644 --- a/packages/playbooks/src/operations/update-playbook.test.ts +++ b/packages/playbooks/src/operations/update-playbook.test.ts @@ -43,7 +43,7 @@ describe('update_playbook', () => { description: '', status: 'active', created_at: firstMoment, - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: later, }, }); diff --git a/packages/playbooks/src/plain-language/playbook-words.test.ts b/packages/playbooks/src/plain-language/playbook-words.test.ts index 8f25d2efb..3bede6160 100644 --- a/packages/playbooks/src/plain-language/playbook-words.test.ts +++ b/packages/playbooks/src/plain-language/playbook-words.test.ts @@ -9,7 +9,7 @@ const sales = { description: 'Answers questions about the pipeline', status: 'active', created_at: '2026-10-02T09:00:00.000Z', - created_by: 'acme-admin', + created_by: { id: 'acme-admin', name: 'acme-admin' }, updated_at: '2026-10-02T09:00:00.000Z', }; diff --git a/packages/playbooks/src/registry/created-playbooks.test.ts b/packages/playbooks/src/registry/created-playbooks.test.ts index 93ffd178d..070f20ed1 100644 --- a/packages/playbooks/src/registry/created-playbooks.test.ts +++ b/packages/playbooks/src/registry/created-playbooks.test.ts @@ -4,17 +4,20 @@ import { playbookCreatedOf } from './created-playbooks.ts'; const context = { at: '2026-10-01T09:00:00.000Z', by: 'acme-admin' }; +const created = { + playbook: 'alpha', + name: 'Alpha', + description: '', + created_by: { id: 'acme-admin', name: 'acme-admin' }, +}; + describe('the playbook a record of an org creates', () => { it('is named by its creation, and by no other record or one without who created it and when', () => { expect([ - playbookCreatedOf({ - type: 'playbook_created', - data: { playbook: 'alpha', name: 'Alpha', description: '' }, - context, - }), + playbookCreatedOf({ type: 'playbook_created', data: created, context }), playbookCreatedOf({ type: 'playbook_retired', data: { playbook: 'alpha' }, context }), playbookCreatedOf({ type: 'playbook_created', data: 'not a fact', context }), - playbookCreatedOf({ type: 'playbook_created', data: { playbook: 'alpha', name: 'Alpha', description: '' } }), + playbookCreatedOf({ type: 'playbook_created', data: created }), ]).toEqual(['alpha', undefined, undefined, undefined]); }); }); diff --git a/packages/playbooks/src/registry/playbook-commands.ts b/packages/playbooks/src/registry/playbook-commands.ts index 59e951cbf..26cf567a6 100644 --- a/packages/playbooks/src/registry/playbook-commands.ts +++ b/packages/playbooks/src/registry/playbook-commands.ts @@ -1,8 +1,11 @@ +import type { RecordedCaller } from '@on.auto/operations'; + export interface PlaybookCreation { readonly type: 'create'; readonly playbook: string; readonly name: string; readonly description: string; + readonly created_by: RecordedCaller; } export interface PlaybookUpdate { diff --git a/packages/playbooks/src/registry/playbook-events.ts b/packages/playbooks/src/registry/playbook-events.ts index 2492b846a..2a4cfbc51 100644 --- a/packages/playbooks/src/registry/playbook-events.ts +++ b/packages/playbooks/src/registry/playbook-events.ts @@ -1,4 +1,4 @@ -import { ActedContextSchema, PlaybookIdSchema, factOf, type Decided } from '@on.auto/operations'; +import { ActedContextSchema, PlaybookIdSchema, RecordedCallerSchema, factOf, type Decided } from '@on.auto/operations'; import { Schema } from 'effect'; const ofThePlaybook = { playbook: PlaybookIdSchema }; @@ -6,7 +6,12 @@ const ofThePlaybook = { playbook: PlaybookIdSchema }; export const PlaybookEventSchema = Schema.Union([ factOf( 'playbook_created', - Schema.Struct({ ...ofThePlaybook, name: Schema.String, description: Schema.String }), + Schema.Struct({ + ...ofThePlaybook, + name: Schema.String, + description: Schema.String, + created_by: RecordedCallerSchema, + }), ActedContextSchema, ), factOf( diff --git a/packages/playbooks/src/registry/playbook.ts b/packages/playbooks/src/registry/playbook.ts index a042443d3..35ae80bfe 100644 --- a/packages/playbooks/src/registry/playbook.ts +++ b/packages/playbooks/src/registry/playbook.ts @@ -1,4 +1,4 @@ -import { PlaybookIdSchema } from '@on.auto/operations'; +import { PlaybookIdSchema, RecordedCallerSchema } from '@on.auto/operations'; import { Schema } from 'effect'; export const PlaybookSchema = Schema.Struct({ @@ -7,7 +7,7 @@ export const PlaybookSchema = Schema.Struct({ description: Schema.String.annotate({ description: 'What the playbook is for, or empty' }), status: Schema.Literals(['active', 'retired']).annotate({ description: 'active, or retired for good' }), created_at: Schema.String.annotate({ description: 'When the playbook was created, in ISO 8601 UTC' }), - created_by: Schema.String.annotate({ description: 'The id of the caller who created the playbook' }), + created_by: RecordedCallerSchema.annotate({ description: 'The caller who created the playbook, by id and name' }), updated_at: Schema.String.annotate({ description: 'When the playbook last changed, in ISO 8601 UTC' }), retired_at: Schema.optionalKey( Schema.String.annotate({ description: 'When the playbook was retired, in ISO 8601 UTC' }), diff --git a/packages/playbooks/src/registry/registry-decider.test.ts b/packages/playbooks/src/registry/registry-decider.test.ts index 5b6aa615b..2d83b70fb 100644 --- a/packages/playbooks/src/registry/registry-decider.test.ts +++ b/packages/playbooks/src/registry/registry-decider.test.ts @@ -8,13 +8,15 @@ import { registryDecider } from './registry-decider.ts'; const recordedPlaybookEvent = recordingOf(registryDecider.eventSchema); -const creation = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; +const ada = { id: 'person:user_2ada', name: 'Ada Lovelace' }; + +const creation = { by: ada.id, at: '2026-10-01T09:00:00.000Z' }; const change = { by: 'acme-editor', at: '2026-10-02T10:30:00.000Z' }; const alphaCreated: PlaybookEvent = { type: 'playbook_created', - data: { playbook: 'alpha', name: 'Alpha', description: 'Answers sales questions' }, + data: { playbook: 'alpha', name: 'Alpha', description: 'Answers sales questions', created_by: ada }, }; const alphaRetired: PlaybookEvent = { type: 'playbook_retired', data: { playbook: 'alpha' } }; @@ -51,10 +53,11 @@ describe('creating a playbook', () => { playbook: 'alpha', name: 'Alpha', description: 'Answers sales questions', + created_by: ada, ...creation, }; - it('records the playbook, with who created it and when as its context', () => { + it('records the playbook with the caller who created it, by id and name, and who and when as its context', () => { expect(decided(creating)).toStrictEqual(Result.succeed([alphaCreated])); expect(registryDecider.context(creating, registryAfter())).toStrictEqual(creation); }); @@ -126,7 +129,10 @@ describe("an org's playbook registry", () => { it('holds each playbook as its facts left it', () => { const registry = registryAfter( alphaCreated, - recorded({ type: 'playbook_created', data: { playbook: 'beta', name: 'Beta', description: '' } }, creation), + recorded( + { type: 'playbook_created', data: { playbook: 'beta', name: 'Beta', description: '', created_by: ada } }, + creation, + ), { type: 'playbook_updated', data: { playbook: 'alpha', name: 'Alpha Sales' } }, { type: 'playbook_updated', data: { playbook: 'beta', description: 'Handles support' } }, recorded(alphaRetired, { ...change, at: '2026-10-03T08:00:00.000Z' }), @@ -139,7 +145,7 @@ describe("an org's playbook registry", () => { description: 'Answers sales questions', status: 'retired', created_at: '2026-10-01T09:00:00.000Z', - created_by: 'acme-admin', + created_by: ada, updated_at: '2026-10-03T08:00:00.000Z', retired_at: '2026-10-03T08:00:00.000Z', }, @@ -149,7 +155,7 @@ describe("an org's playbook registry", () => { description: 'Handles support', status: 'active', created_at: '2026-10-01T09:00:00.000Z', - created_by: 'acme-admin', + created_by: ada, updated_at: '2026-10-02T10:30:00.000Z', }, ]); diff --git a/packages/playbooks/src/registry/registry-decisions.ts b/packages/playbooks/src/registry/registry-decisions.ts index 31e278e9f..4da07aa9b 100644 --- a/packages/playbooks/src/registry/registry-decisions.ts +++ b/packages/playbooks/src/registry/registry-decisions.ts @@ -31,10 +31,10 @@ function takenBy({ id, status }: Playbook): Conflict { }); } -function decideCreation({ playbook, name, description }: PlaybookCreation, registry: Registry): Decision { +function decideCreation({ playbook, name, description, created_by }: PlaybookCreation, registry: Registry): Decision { const existing = registry.get(playbook); return existing === undefined - ? recording({ type: 'playbook_created', data: { playbook, name, description } }) + ? recording({ type: 'playbook_created', data: { playbook, name, description, created_by } }) : Result.fail(takenBy(existing)); } diff --git a/packages/playbooks/src/registry/registry.ts b/packages/playbooks/src/registry/registry.ts index 1dc349de2..9d74d9eeb 100644 --- a/packages/playbooks/src/registry/registry.ts +++ b/packages/playbooks/src/registry/registry.ts @@ -7,10 +7,9 @@ export type Registry = ReadonlyMap; export const initialRegistry: Registry = new Map(); -function createdPlaybook({ data, context }: PlaybookCreated): Playbook { - const { playbook, name, description } = data; - const { at, by } = context; - return { id: playbook, name, description, status: 'active', created_at: at, created_by: by, updated_at: at }; +function createdPlaybook({ data, context: { at } }: PlaybookCreated): Playbook { + const { playbook, name, description, created_by } = data; + return { id: playbook, name, description, status: 'active', created_at: at, created_by, updated_at: at }; } function updatedPlaybook(playbook: Playbook, { data, context }: PlaybookUpdated): Playbook { diff --git a/packages/playbooks/src/testing/callers.ts b/packages/playbooks/src/testing/callers.ts index c2f324790..9d1ea6db0 100644 --- a/packages/playbooks/src/testing/callers.ts +++ b/packages/playbooks/src/testing/callers.ts @@ -1,9 +1,18 @@ import { allPermissions, type CallerIdentity } from '@on.auto/operations'; -export const acmeAdmin: CallerIdentity = { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }; +export const acmeAdmin: CallerIdentity = { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: allPermissions, + playbooks: '*', +}; + +export const adaOfAcme: CallerIdentity = { ...acmeAdmin, id: 'person:user_2ada', name: 'Ada Lovelace' }; export const acmeAlphaKeeper: CallerIdentity = { id: 'acme-alpha-keeper', + name: 'acme-alpha-keeper', org: 'acme', permissions: ['org:read', 'org:write'], playbooks: ['alpha'], @@ -11,11 +20,18 @@ export const acmeAlphaKeeper: CallerIdentity = { export const acmeGammaReader: CallerIdentity = { id: 'acme-gamma-reader', + name: 'acme-gamma-reader', org: 'acme', permissions: ['playbook:read'], playbooks: ['gamma'], }; -export const acmeReader: CallerIdentity = { id: 'acme-reader', org: 'acme', permissions: ['org:read'], playbooks: '*' }; +export const acmeReader: CallerIdentity = { + id: 'acme-reader', + name: 'acme-reader', + org: 'acme', + permissions: ['org:read'], + playbooks: '*', +}; -export const globexAdmin: CallerIdentity = { ...acmeAdmin, id: 'globex-admin', org: 'globex' }; +export const globexAdmin: CallerIdentity = { ...acmeAdmin, id: 'globex-admin', name: 'globex-admin', org: 'globex' }; diff --git a/packages/server/src/composition/composition-root.test.ts b/packages/server/src/composition/composition-root.test.ts index 59b977d59..efb029088 100644 --- a/packages/server/src/composition/composition-root.test.ts +++ b/packages/server/src/composition/composition-root.test.ts @@ -10,7 +10,13 @@ import { logsToStderr } from '../logging/log-formats.ts'; import { applicationLayer } from './application-layer.ts'; import { factsComposedOf } from './composed-facts.ts'; -const acmeAdmin: CallerIdentity = { id: 'acme-admin', org: 'acme', permissions: allPermissions, playbooks: '*' }; +const acmeAdmin: CallerIdentity = { + id: 'acme-admin', + name: 'acme-admin', + org: 'acme', + permissions: allPermissions, + playbooks: '*', +}; const statusOfAlpha = Effect.gen(function* () { const registry = yield* PlaybookRegistry; diff --git a/packages/server/src/connections/connections-over-http.test.ts b/packages/server/src/connections/connections-over-http.test.ts index 0046213cc..44818ac38 100644 --- a/packages/server/src/connections/connections-over-http.test.ts +++ b/packages/server/src/connections/connections-over-http.test.ts @@ -1,3 +1,4 @@ +import { localDeveloper } from '@on.auto/identity/testing'; import { Schema } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; @@ -53,7 +54,10 @@ describe('adding a connection over HTTP in local mode', () => { const { answer, id, page, token } = await added(served); - expect(answer).toMatchObject({ status: 201, body: { id, name: 'crm', status: 'pending', added_by: 'local' } }); + expect(answer).toMatchObject({ + status: 201, + body: { id, name: 'crm', status: 'pending', added_by: { id: 'local', name: localDeveloper.name } }, + }); expect(Object.fromEntries(page.searchParams)).toEqual({ connection: id, token }); expect(`${page.origin}${page.pathname}`).toBe(`${served.origin}/studio/connect`); }); diff --git a/packages/server/src/connections/connections-over-mcp.test.ts b/packages/server/src/connections/connections-over-mcp.test.ts index 9d0e7fb92..015731433 100644 --- a/packages/server/src/connections/connections-over-mcp.test.ts +++ b/packages/server/src/connections/connections-over-mcp.test.ts @@ -7,6 +7,7 @@ import { withMcpSession, type LinkOpening, } from '@on.auto/api/testing'; +import { localDeveloper } from '@on.auto/identity/testing'; import { sha256Of } from '@on.auto/operations'; import { Schema } from 'effect'; import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest'; @@ -129,7 +130,11 @@ describe('the retry of add_connection', () => { const listed = await next.call('GET', '/v1/orgs/local/connections'); await next.stop(); - expect(retried.structuredContent).toMatchObject({ name: 'notes-across', status: 'pending', added_by: 'local' }); + expect(retried.structuredContent).toMatchObject({ + name: 'notes-across', + status: 'pending', + added_by: { id: 'local', name: localDeveloper.name }, + }); expect(listed.body).toMatchObject({ connections: [{ name: 'notes-across' }] }); expect(await ownKeyFactsIn(fileName)).toMatchObject([ { type: 'own_key_kept', data: { purpose: 'retry_state' }, context: { by: 'runtime' } }, diff --git a/packages/server/src/connections/connections-signed-in.test.ts b/packages/server/src/connections/connections-signed-in.test.ts index 70a067627..c7d87a776 100644 --- a/packages/server/src/connections/connections-signed-in.test.ts +++ b/packages/server/src/connections/connections-signed-in.test.ts @@ -40,6 +40,7 @@ function tokenOfAnotherPerson(issuer: FakeIssuer): Promise { return issuer.sign({ iss: issuer.issuer, sub: 'user|grace', + name: 'Grace Hopper', aud: publicUrl, iat: seconds, exp: seconds + 300, @@ -61,7 +62,7 @@ describe('a connection on a runtime that signs people in', () => { const byGrace = await server.call('POST', `${connections}/${id}/approve`, { key: grace, body: { token } }); const byAda = await server.call('POST', `${connections}/${id}/approve`, { key: ada, body: { token } }); - expect(answer).toMatchObject({ status: 201, body: { added_by: 'person:user|ada' } }); + expect(answer).toMatchObject({ status: 201, body: { added_by: { id: 'person:user|ada', name: 'Ada Lovelace' } } }); expect(named).toBe(publicUrl); expect(byGrace).toMatchObject({ status: 403, diff --git a/packages/server/src/interaction/interaction-over-http.test.ts b/packages/server/src/interaction/interaction-over-http.test.ts index 39b635e51..f0446e59e 100644 --- a/packages/server/src/interaction/interaction-over-http.test.ts +++ b/packages/server/src/interaction/interaction-over-http.test.ts @@ -1,3 +1,4 @@ +import { localDeveloper } from '@on.auto/identity/testing'; import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -7,6 +8,8 @@ import { runIdIn } from '../testing/servers/workflow-server.ts'; const answerIdOf = Schema.decodeUnknownSync(Schema.Struct({ answer_id: Schema.String })); +const developer = { id: 'local', name: localDeveloper.name }; + describe('an interaction function through the inbox, over HTTP', () => { it('leaves its request in the inbox and takes a checked answer as the output, once, which its history shows', async () => { const server = await servingInteractions(); @@ -29,7 +32,7 @@ describe('an interaction function through the inbox, over HTTP', () => { expect(listed).toMatchObject({ status: 200, body: { interactions: [{ run_id: runId, type: 'interaction' }] } }); expect([invalid.status, answered.status, again.status, another.status]).toEqual([422, 200, 200, 409]); expect([answered.body, again.body]).toMatchObject([ - { run_id: runId, answer: { choice: 'approve' }, claimed_for: 'the campaign team', answered_by: 'local' }, + { run_id: runId, answer: { choice: 'approve' }, claimed_for: 'the campaign team', answered_by: developer }, { claimed_for: 'the campaign team' }, ]); expect(invalid.body).toMatchObject({ errors: [{ pointer: '/answer/choice' }] }); diff --git a/packages/server/src/main.test.ts b/packages/server/src/main.test.ts index 41f6ba2a6..c2c26edd6 100644 --- a/packages/server/src/main.test.ts +++ b/packages/server/src/main.test.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'; import { withMcpSession } from '@on.auto/api/testing'; import { createApiKey } from '@on.auto/identity'; +import { localDeveloper } from '@on.auto/identity/testing'; import { afterAll, describe, expect, it } from 'vitest'; import { spawnServer } from './testing/processes/spawned-server.ts'; @@ -244,7 +245,9 @@ describe('main over MCP', () => { child.signal('SIGTERM'); expect({ playbook: playbook.structuredContent, exitCode: await child.exited }).toMatchObject({ - playbook: { playbooks: [{ id: 'support', name: 'Support', created_by: 'local' }] }, + playbook: { + playbooks: [{ id: 'support', name: 'Support', created_by: { id: 'local', name: localDeveloper.name } }], + }, exitCode: 0, }); expect(child.output().stdout).toBe(`Auto Runtime listening on port ${port}\n`); diff --git a/packages/server/src/mcp/answering-what-runs-wait-on.test.ts b/packages/server/src/mcp/answering-what-runs-wait-on.test.ts index 1f365d6e7..df60c567f 100644 --- a/packages/server/src/mcp/answering-what-runs-wait-on.test.ts +++ b/packages/server/src/mcp/answering-what-runs-wait-on.test.ts @@ -152,7 +152,7 @@ describe('episode 7: the person approves, in the chat, a draft a workflow sent t const guide = await meetings.onMcp((session) => session.callTool('get_guide', { guide: 'interaction-function' })); expect(sentenceNaming(instructions, 'answer_interaction')).toBe( - 'When the person approves, rejects or otherwise answers what a run waits on, answer its request with answer_interaction, not a new run.', + 'When the person approves, rejects or otherwise answers what a run waits on, use answer_interaction, not a new run.', ); expect(descriptionIn(meetings.surfaces, 'run_definition')).toContain( 'a run that finishes later answers started with a run_id unless it ends before its first wait, and get_run shows how it ended.', diff --git a/packages/server/src/mcp/mcp-endpoint-keys.test.ts b/packages/server/src/mcp/mcp-endpoint-keys.test.ts index 065260aa6..21a89125c 100644 --- a/packages/server/src/mcp/mcp-endpoint-keys.test.ts +++ b/packages/server/src/mcp/mcp-endpoint-keys.test.ts @@ -186,7 +186,7 @@ describe('a key that may only read inside playbooks, on /mcp', () => { expect(outcome.tools).toEqual(['list_playbooks', 'search', ...queriesInsideAPlaybook, 'get_guide']); expect(commands.filter((name) => new RegExp(`\\b${name}\\b`, 'u').test(outcome.instructions))).toEqual([]); expect(outcome.instructions).toContain( - "This connection acts in the caller's own org: list_playbooks shows its playbooks. get_guide holds what these words mean", + "The org is the caller's own: list_playbooks shows its playbooks. get_guide explains these words", ); expect(outcome.listed.structuredContent).toMatchObject({ playbooks: [{ id: 'alpha' }, { id: 'beta' }] }); }); diff --git a/packages/server/src/mcp/mcp-endpoint.test.ts b/packages/server/src/mcp/mcp-endpoint.test.ts index d1460bdca..027dec8b0 100644 --- a/packages/server/src/mcp/mcp-endpoint.test.ts +++ b/packages/server/src/mcp/mcp-endpoint.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; -import { instructionsFor, type McpEndpoint } from '@on.auto/api'; +import { instructionsActingAs, type McpEndpoint } from '@on.auto/api'; import { danglingReferencesIn, guideToolName, @@ -11,6 +11,7 @@ import { type ListedTool, type McpSession, } from '@on.auto/api/testing'; +import { localDeveloper } from '@on.auto/identity/testing'; import { answers, textResult } from '@on.auto/reasoning/testing'; import { Schema } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; @@ -137,6 +138,7 @@ const resourcesOnTheTerminologyPage: ReadonlySet = new Set( interface Connection { readonly path: string; + readonly org: string; readonly endpoint: McpEndpoint; readonly served: { readonly orgTools: readonly string[]; readonly playbookTools: readonly string[] }; readonly sentence: string; @@ -146,20 +148,23 @@ interface Connection { const connections: readonly Connection[] = [ { path: '/mcp', + org: 'local', endpoint: 'own org', served: { orgTools, playbookTools: insideAPlaybookAlone }, - sentence: "This connection acts in the caller's own org: list_playbooks shows its playbooks", + sentence: "The org is the caller's own: list_playbooks shows its playbooks", unnamed: [], }, { path: '/orgs/acme/mcp', + org: 'acme', endpoint: 'org', served: { orgTools, playbookTools: [] }, - sentence: 'This connection manages the playbooks of one org: list_playbooks shows them', + sentence: "This connection manages the org's playbooks: list_playbooks shows them", unnamed: ['create_definition', 'run_definition', 'get_run', 'dry_run', 'answer_interaction', 'send_run_event'], }, { path: '/orgs/acme/playbooks/alpha/mcp', + org: 'acme', endpoint: 'playbook', served: { orgTools: [], playbookTools: definitionTools }, sentence: 'This connection acts inside one playbook.', @@ -169,14 +174,16 @@ const connections: readonly Connection[] = [ describe('the instructions an agent receives when it connects', () => { it.each(connections)( - 'on $path say what a playbook, a definition and a run are, and name only the tools the endpoint serves', - async ({ path, endpoint, served, sentence, unnamed }) => { + 'on $path say whom they act as in which org, what a playbook, a definition and a run are, and name only the tools the endpoint serves', + async ({ path, org, endpoint, served, sentence, unnamed }) => { server = await servingReasoning([]); await onMcp('/mcp', (session) => session.callTool('create_playbook', { playbook: 'alpha', name: 'Alpha' })); const instructions = await onMcp(path, (session) => Promise.resolve(session.instructions)); - expect(instructions).toBe(instructionsFor(endpoint, served, definitionTypes)); + expect(instructions).toBe( + instructionsActingAs({ caller: localDeveloper, org }, endpoint, served, definitionTypes), + ); expect(instructions).toContain(sentence); expect(unnamed.filter((name) => instructions?.includes(name) === true)).toEqual([]); }, @@ -284,6 +291,8 @@ describe('/mcp in local mode', () => { await onMcp('/mcp', (session) => session.callTool('create_playbook', { playbook: 'alpha', name: 'Alpha' })); const listed = await server.call('GET', '/v1/orgs/local/playbooks'); - expect(listed.body).toMatchObject({ playbooks: [{ id: 'alpha', created_by: 'local' }] }); + expect(listed.body).toMatchObject({ + playbooks: [{ id: 'alpha', created_by: { id: 'local', name: localDeveloper.name } }], + }); }); }); diff --git a/packages/server/src/mcp/speaking-to-agents.test.ts b/packages/server/src/mcp/speaking-to-agents.test.ts index 16ac9a964..5731c0d78 100644 --- a/packages/server/src/mcp/speaking-to-agents.test.ts +++ b/packages/server/src/mcp/speaking-to-agents.test.ts @@ -51,11 +51,11 @@ const posting = [ ].join('\n'); describe('episode 1: asked "what is a playbook?"', () => { - it('is answered by the first three sentences of the instructions, without any tool or rule', () => { - const answer = sentencesOf(surfaces.instructions).slice(0, 3).join(' '); + it('is answered by the three sentences after the one that says whom the connection acts as, without any tool or rule', () => { + const answer = sentencesOf(surfaces.instructions).slice(1, 4).join(' '); expect(answer).toBe( - 'Whatever the person wants done that should keep working after this conversation, however they put it and in any line of business, is a playbook on this server, not a document, a plan, a note or a one-off answer. Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows.', + 'Whatever the person wants done that keeps working after this conversation, however they put it and in any line of business, is a playbook here, not a document, plan, note or one-off answer. Auto Runtime makes and runs playbooks; Studio manages them; your agent is a separate client. A playbook contains one brain (its reusable functions) and workflows.', ); expect(internalTermsIn(answer)).toEqual([]); expect(answer).not.toMatch(/[a-z]+_[a-z_]+|\bid\b/u); diff --git a/packages/server/src/openapi/served-document.test.ts b/packages/server/src/openapi/served-document.test.ts index fbd55b3c9..7801e763d 100644 --- a/packages/server/src/openapi/served-document.test.ts +++ b/packages/server/src/openapi/served-document.test.ts @@ -95,6 +95,56 @@ describe('the OpenAPI document of the server', () => { }); }); +const AnsweredFieldsSchema = Schema.Struct({ + content: Schema.Struct({ + 'application/json': Schema.Struct({ + schema: Schema.Struct({ properties: Schema.Record(Schema.String, Schema.Json) }), + }), + }), +}); + +function answeredField(operationId: string, status: string, field: string): unknown { + const described = operationsIn(document).find(({ operation }) => operation.operationId === operationId); + const { content } = Schema.decodeUnknownSync(AnsweredFieldsSchema)(described?.operation.responses[status]); + return content['application/json'].schema.properties[field]; +} + +function theCallerWho(did: string) { + return { + type: 'object', + description: `The caller who ${did}, by id and name`, + properties: { id: { type: 'string', description: 'The id of the caller' }, name: { type: 'string' } }, + required: ['id', 'name'], + }; +} + +describe('who did something, in the document', () => { + it('is the caller who did it, by id and name, on a playbook, a definition, a connection, a run and an answer', () => { + expect([ + answeredField('create_playbook', '201', 'created_by'), + answeredField('get_definition', '200', 'created_by'), + answeredField('add_connection', '201', 'added_by'), + answeredField('get_run', '200', 'started_by'), + answeredField('answer_interaction', '200', 'answered_by'), + ]).toMatchObject([ + theCallerWho('created the playbook'), + theCallerWho('created the definition'), + theCallerWho('added it'), + theCallerWho('started the run'), + theCallerWho('answered it'), + ]); + }); + + it('is the caller who connected a connection, by id and name, or null while nobody has', () => { + const { description, ...caller } = theCallerWho('connected it, approving it or giving its credential'); + + expect(answeredField('get_connection', '200', 'connected_by')).toMatchObject({ + anyOf: [caller, { type: 'null' }], + description, + }); + }); +}); + function shapeOf({ path, method, operation }: DocumentedOperation): readonly string[] { return [ operation.operationId, diff --git a/packages/server/src/playbooks/playbooks-over-http.test.ts b/packages/server/src/playbooks/playbooks-over-http.test.ts index b32df8a8c..454bf906d 100644 --- a/packages/server/src/playbooks/playbooks-over-http.test.ts +++ b/packages/server/src/playbooks/playbooks-over-http.test.ts @@ -1,3 +1,4 @@ +import { localDeveloper } from '@on.auto/identity/testing'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { compositionRoot } from '../composition/composition-root.ts'; @@ -45,7 +46,13 @@ describe('the playbooks of an org over HTTP in local mode', () => { expect(created).toMatchObject({ status: 201, - body: { id: 'alpha', name: 'Alpha', description: 'Sales', status: 'active', created_by: 'local' }, + body: { + id: 'alpha', + name: 'Alpha', + description: 'Sales', + status: 'active', + created_by: { id: 'local', name: localDeveloper.name }, + }, }); expect(cacheControlOf(created)).toBe('no-store'); }); diff --git a/packages/server/src/recall/what-the-person-wants-kept.test.ts b/packages/server/src/recall/what-the-person-wants-kept.test.ts index 7cea95dce..4acec619e 100644 --- a/packages/server/src/recall/what-the-person-wants-kept.test.ts +++ b/packages/server/src/recall/what-the-person-wants-kept.test.ts @@ -79,7 +79,7 @@ describe('episode 11: asked to keep the revenue of each month, to ask later for })); expect(sentenceNaming(instructions, 'wants kept')).toBe( - 'What the person wants kept goes in a recall function, for anyone or a workflow to ask later, not your notes.', + 'What the person wants kept goes in a recall function for anyone or a workflow to ask later, not your notes.', ); expect(String(prompt.messages[0]?.content.text)).toContain( "3. For what the person tells you, publish it to the playbook with publish_event once they agree, one event for each fact, such as each month's figure:", diff --git a/packages/server/src/sign-in/issuer-over-https.ts b/packages/server/src/sign-in/issuer-over-https.ts index e532793ac..731c9b2f5 100644 --- a/packages/server/src/sign-in/issuer-over-https.ts +++ b/packages/server/src/sign-in/issuer-over-https.ts @@ -26,6 +26,7 @@ export function accessTokenOf(issuer: FakeIssuer, audience: string, scope: strin return issuer.sign({ iss: issuer.issuer, sub: 'user|ada', + name: 'Ada Lovelace', aud: audience, iat: seconds, exp: seconds + 300, diff --git a/packages/server/src/sign-in/signed-in-persons.test.ts b/packages/server/src/sign-in/signed-in-persons.test.ts index b6ac7baa1..ed3533ae6 100644 --- a/packages/server/src/sign-in/signed-in-persons.test.ts +++ b/packages/server/src/sign-in/signed-in-persons.test.ts @@ -19,7 +19,7 @@ function signingIn(issuer: string): Readonly> { } describe('a signed-in person on the facts', () => { - it('is named person: as who created the playbook and the definition, started the run and answered it', async () => { + it('is named person:, with the name its token gives, as who created the playbook and the definition, started the run and answered it', async () => { const issuer = await issuerOverHttps(); const server = await interactionServerOn(signingIn(issuer.issuer)); const key = await accessTokenOf(issuer, publicUrl, signInScopes.join(' ')); @@ -44,10 +44,10 @@ describe('a signed-in person on the facts', () => { const run = await server.call('GET', `${alpha}/runs/${runId}`, { key }); expect([playbook.body, definition.body, run.body, answered.body]).toMatchObject([ - { created_by: 'person:user|ada' }, - { created_by: 'person:user|ada' }, - { started_by: 'person:user|ada' }, - { answered_by: 'person:user|ada' }, + { created_by: { id: 'person:user|ada', name: 'Ada Lovelace' } }, + { created_by: { id: 'person:user|ada', name: 'Ada Lovelace' } }, + { started_by: { id: 'person:user|ada', name: 'Ada Lovelace' } }, + { answered_by: { id: 'person:user|ada', name: 'Ada Lovelace' } }, ]); }); }); @@ -142,7 +142,7 @@ describe('a page signed in to Auto, calling a server that signs persons in', () const created = await createdWith(server.origin, session, page); expect([created.status, created.headers.get('access-control-allow-origin')]).toEqual([201, page]); - expect(await created.json()).toMatchObject({ created_by: 'person:user|ada' }); + expect(await created.json()).toMatchObject({ created_by: { id: 'person:user|ada', name: 'Ada Lovelace' } }); }); it('refuses the session another page was given, naming its azp claim', async () => { @@ -187,8 +187,8 @@ describe('a server in local mode', () => { body: { playbook: 'beta', name: 'Beta' }, }); - expect([fromThePage.status, await fromThePage.json()]).toMatchObject([201, { created_by: 'local' }]); - expect(withAKey).toMatchObject({ status: 201, body: { created_by: 'local' } }); + expect([fromThePage.status, await fromThePage.json()]).toMatchObject([201, { created_by: { id: 'local' } }]); + expect(withAKey).toMatchObject({ status: 201, body: { created_by: { id: 'local' } } }); }); }); diff --git a/packages/server/src/workflow-runs/reactions-over-http.test.ts b/packages/server/src/workflow-runs/reactions-over-http.test.ts index cdfae4a6d..64af07b52 100644 --- a/packages/server/src/workflow-runs/reactions-over-http.test.ts +++ b/packages/server/src/workflow-runs/reactions-over-http.test.ts @@ -11,7 +11,7 @@ const ListedRuns = Schema.Struct({ run_id: Schema.String, name: Schema.String, status: Schema.String, - started_by: Schema.String, + started_by: Schema.Struct({ id: Schema.String, name: Schema.String }), }), ), }); @@ -85,7 +85,7 @@ describe('a workflow whose trigger is an event', () => { }); const { run, settled } = await triggeredRunOf('close-the-month'); - expect(run).toMatchObject({ status: 'succeeded', started_by: 'playbook:alpha' }); + expect(run).toMatchObject({ status: 'succeeded', started_by: { id: 'playbook:alpha', name: 'alpha' } }); expect(settled.body).toMatchObject({ status: 'succeeded', output: { month: 'september' } }); }); diff --git a/packages/server/src/workflow-runs/reactions-over-mcp.test.ts b/packages/server/src/workflow-runs/reactions-over-mcp.test.ts index b421fab7f..d45a67519 100644 --- a/packages/server/src/workflow-runs/reactions-over-mcp.test.ts +++ b/packages/server/src/workflow-runs/reactions-over-mcp.test.ts @@ -12,7 +12,9 @@ const closing = workflowSource( ); const ListedRuns = Schema.Struct({ - runs: Schema.Array(Schema.Struct({ status: Schema.String, started_by: Schema.String })), + runs: Schema.Array( + Schema.Struct({ status: Schema.String, started_by: Schema.Struct({ id: Schema.String, name: Schema.String }) }), + ), }); const PlaybookEvents = Schema.Struct({ events: Schema.Array(Schema.Struct({ type: Schema.String })) }); @@ -59,7 +61,7 @@ describe('a workflow whose trigger is an event, over MCP', () => { return { runs, feed: eventsIn(feed.structuredContent).events.map(({ type }) => type) }; }); - expect(seen.runs.runs).toEqual([{ status: 'succeeded', started_by: 'playbook:alpha' }]); + expect(seen.runs.runs).toEqual([{ status: 'succeeded', started_by: { id: 'playbook:alpha', name: 'alpha' } }]); expect(seen.feed).toEqual(expect.arrayContaining(['event_published', 'run_started', 'run_succeeded'])); }); }); diff --git a/packages/server/src/workflow-runs/several-triggers.test.ts b/packages/server/src/workflow-runs/several-triggers.test.ts index 07de46e4f..dfc0927d8 100644 --- a/packages/server/src/workflow-runs/several-triggers.test.ts +++ b/packages/server/src/workflow-runs/several-triggers.test.ts @@ -29,7 +29,13 @@ const closing = workflowSource( const TriggerSchema = Schema.Struct({ kind: Schema.String, reference: Schema.String }); const ListedRuns = Schema.Struct({ - runs: Schema.Array(Schema.Struct({ run_id: Schema.String, status: Schema.String, started_by: Schema.String })), + runs: Schema.Array( + Schema.Struct({ + run_id: Schema.String, + status: Schema.String, + started_by: Schema.Struct({ id: Schema.String, name: Schema.String }), + }), + ), }); const StartSchema = Schema.Struct({ @@ -131,6 +137,8 @@ afterAll(async () => { await server.stop(); }); +const thePlaybook = { id: 'playbook:alpha', name: 'alpha' }; + describe('a workflow with an event trigger, a cron schedule and an every schedule', () => { it('shows its triggers in the order its document names them, over HTTP and MCP', () => { expect(seen.definition.triggers).toEqual([ @@ -143,9 +151,9 @@ describe('a workflow with an event trigger, a cron schedule and an every schedul it('runs as the playbook once for an event and once at a due time of each schedule', () => { expect(seen.runs.runs.map(({ status, started_by: by }) => [status, by])).toEqual([ - ['succeeded', 'playbook:alpha'], - ['succeeded', 'playbook:alpha'], - ['succeeded', 'playbook:alpha'], + ['succeeded', thePlaybook], + ['succeeded', thePlaybook], + ['succeeded', thePlaybook], ]); expect( seen.starts.map(({ metadata }) => metadata.trigger.kind).toSorted((first, second) => first.localeCompare(second)), diff --git a/packages/server/src/workflow-runs/workflow-access-over-http.test.ts b/packages/server/src/workflow-runs/workflow-access-over-http.test.ts index 8b46814d4..498faaefc 100644 --- a/packages/server/src/workflow-runs/workflow-access-over-http.test.ts +++ b/packages/server/src/workflow-runs/workflow-access-over-http.test.ts @@ -93,10 +93,19 @@ describe('a workflow that listens for an event, over HTTP', () => { expect(sent).toMatchObject({ status: 200, body: { run_id: runId, event: decided } }); expect(Object.keys(sentEventOf(sent.body).event)).toEqual(['type', 'source', 'data', 'id', 'time']); expect(settled).toMatchObject({ - body: { status: 'succeeded', output: [{ approved: true }], started_by: 'acme-runner' }, + body: { + status: 'succeeded', + output: [{ approved: true }], + started_by: { id: 'acme-runner', name: 'acme-runner' }, + }, }); expect(await server.call('GET', `${alpha}/runs/${String(nested)}`, { key: admin.key })).toMatchObject({ - body: { type: 'reasoning', name: 'verdict', status: 'succeeded', started_by: 'acme-runner' }, + body: { + type: 'reasoning', + name: 'verdict', + status: 'succeeded', + started_by: { id: 'acme-runner', name: 'acme-runner' }, + }, }); }); }); diff --git a/packages/server/src/workflow-runs/workflows-over-http.test.ts b/packages/server/src/workflow-runs/workflows-over-http.test.ts index ed00912de..ebe2ee629 100644 --- a/packages/server/src/workflow-runs/workflows-over-http.test.ts +++ b/packages/server/src/workflow-runs/workflows-over-http.test.ts @@ -1,3 +1,4 @@ +import { localDeveloper } from '@on.auto/identity/testing'; import { answers, jsonResult, textResult, type ScriptedReply } from '@on.auto/reasoning/testing'; import { Schema } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; @@ -116,6 +117,8 @@ async function ran(name: string, input: object): Promise { return runIdIn(response.body); } +const startedLocally = { started_by: { id: 'local', name: localDeveloper.name } }; + describe('a workflow that runs reasoning functions, over HTTP', () => { it('branches on the first answer, executes the second definition, and settles succeeded with what it composed', async () => { await serving(answers(jsonResult({ approve: true })), answers(textResult('Dinner for six, within policy.'))); @@ -131,7 +134,7 @@ describe('a workflow that runs reasoning functions, over HTTP', () => { name: 'expense-review', status: 'succeeded', output: { approved: true, summary: 'Dinner for six, within policy.' }, - started_by: 'local', + ...startedLocally, }, }); }); @@ -147,8 +150,8 @@ describe('a workflow that runs reasoning functions, over HTTP', () => { expect(new Set([runId, ...server.modelRunIds()]).size).toBe(3); expect(nested.map(({ body }) => body)).toMatchObject([ - { type: 'reasoning', name: 'verdict', status: 'succeeded', output: { approve: true }, started_by: 'local' }, - { type: 'reasoning', name: 'summary', status: 'succeeded', output: 'Within policy.', started_by: 'local' }, + { type: 'reasoning', name: 'verdict', status: 'succeeded', output: { approve: true }, ...startedLocally }, + { type: 'reasoning', name: 'summary', status: 'succeeded', output: 'Within policy.', ...startedLocally }, ]); }); diff --git a/packages/server/src/workflows/reaction-dependencies.test.ts b/packages/server/src/workflows/reaction-dependencies.test.ts index 67049bee6..adb47090c 100644 --- a/packages/server/src/workflows/reaction-dependencies.test.ts +++ b/packages/server/src/workflows/reaction-dependencies.test.ts @@ -46,6 +46,7 @@ describe('a start of a workflow its trigger matched', () => { { caller: { id: 'playbook:alpha', + name: 'alpha', org: 'acme', permissions: ['playbook:read', 'playbook:write'], playbooks: ['alpha'], diff --git a/packages/tool-servers/src/search/search-in-org.test.ts b/packages/tool-servers/src/search/search-in-org.test.ts index 1530d09ef..7e5bd438e 100644 --- a/packages/tool-servers/src/search/search-in-org.test.ts +++ b/packages/tool-servers/src/search/search-in-org.test.ts @@ -9,12 +9,19 @@ import { serveFakeMcp, type FakeMcpServer } from '../testing/served-fakes.ts'; const apiKey = 'graph-api-key-4f1d9a7c2b'; -const orgReader: CallerIdentity = { id: 'acme-reader', org: 'acme', permissions: ['org:read'], playbooks: '*' }; +const orgReader: CallerIdentity = { + id: 'acme-reader', + name: 'acme-reader', + org: 'acme', + permissions: ['org:read'], + playbooks: '*', +}; const playbookReader: CallerIdentity = { ...orgReader, id: 'acme-playbooks', permissions: ['playbook:read'] }; const alphaReader: CallerIdentity = { id: 'acme-alpha', + name: 'acme-alpha', org: 'acme', permissions: ['org:read', 'playbook:read'], playbooks: ['alpha'], diff --git a/packages/tool-servers/src/search/search-providers.test.ts b/packages/tool-servers/src/search/search-providers.test.ts index bf986238e..ad5b5e709 100644 --- a/packages/tool-servers/src/search/search-providers.test.ts +++ b/packages/tool-servers/src/search/search-providers.test.ts @@ -10,7 +10,13 @@ import { patientTiming, playbookToolCalls } from '../testing/index.ts'; import { reportingAccess } from '../testing/reporting-access.ts'; import { fakeApiKey, serveFakeMcp, type FakeMcpServer } from '../testing/served-fakes.ts'; -const adder: CallerIdentity = { id: 'person:ada', org: 'acme', permissions: ['org:read', 'org:write'], playbooks: '*' }; +const adder: CallerIdentity = { + id: 'person:ada', + name: 'Ada Lovelace', + org: 'acme', + permissions: ['org:read', 'org:write'], + playbooks: '*', +}; const reader: CallerIdentity = { ...adder, id: 'acme-reader', permissions: ['org:read'] }; diff --git a/packages/tool-servers/src/testing/playbook-tool-calls.ts b/packages/tool-servers/src/testing/playbook-tool-calls.ts index 7252c1b0c..2beaf8527 100644 --- a/packages/tool-servers/src/testing/playbook-tool-calls.ts +++ b/packages/tool-servers/src/testing/playbook-tool-calls.ts @@ -25,6 +25,7 @@ import { defineValidate } from '../tool-reads/validate.ts'; export const toolCaller: CallerIdentity = { id: 'acme-builder', + name: 'acme-builder', org: 'acme', permissions: ['playbook:read', 'playbook:write'], playbooks: '*', diff --git a/packages/workflow-host/host-process.ts b/packages/workflow-host/host-process.ts index d6d43bc38..79b82f838 100644 --- a/packages/workflow-host/host-process.ts +++ b/packages/workflow-host/host-process.ts @@ -51,7 +51,7 @@ const settle: SettleRun = ({ id }, settlement) => definition_version: 1, status: 'succeeded', started_at: '2026-10-05T09:00:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, } as const; }); diff --git a/packages/workflow-host/measure/idle-views.ts b/packages/workflow-host/measure/idle-views.ts index 4e9b44624..a9d39bc55 100644 --- a/packages/workflow-host/measure/idle-views.ts +++ b/packages/workflow-host/measure/idle-views.ts @@ -37,7 +37,12 @@ async function saved(store: WorkflowStore, index: number): Promise { }; await store.database.store.append( `${playbook}definitions/recall`, - [{ type: 'definition_created', data: { content: recallContentOf(name, details) } }], + [ + { + type: 'definition_created', + data: { content: recallContentOf(name, details), created_by: { id: 'acme-admin', name: 'acme-admin' } }, + }, + ], { expectedVersion: index, context }, ); } diff --git a/packages/workflow-host/measure/measured-host.ts b/packages/workflow-host/measure/measured-host.ts index 9f1264d6f..3c01d4127 100644 --- a/packages/workflow-host/measure/measured-host.ts +++ b/packages/workflow-host/measure/measured-host.ts @@ -24,7 +24,7 @@ const run: Run = { definition_version: 1, status: 'succeeded', started_at: '2026-10-05T09:00:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, }; export const header = { dsl: '1.0.3', namespace: 'acme', name: 'measured', version: '1.0.0' }; diff --git a/packages/workflow-host/measure/rebuild.ts b/packages/workflow-host/measure/rebuild.ts index 9c98ab60c..b29fe1cd6 100644 --- a/packages/workflow-host/measure/rebuild.ts +++ b/packages/workflow-host/measure/rebuild.ts @@ -70,7 +70,7 @@ async function recorded(database: HostDatabase, events: number): Promise { [ { type: 'definition_created', - data: { content: recallContentOf('reviews', details) }, + data: { content: recallContentOf('reviews', details), created_by: { id: 'acme-admin', name: 'acme-admin' } }, }, ], { expectedVersion: 0, context }, diff --git a/packages/workflow-host/measure/waiting-host.ts b/packages/workflow-host/measure/waiting-host.ts index 1319812bf..6be90bbd8 100644 --- a/packages/workflow-host/measure/waiting-host.ts +++ b/packages/workflow-host/measure/waiting-host.ts @@ -42,7 +42,7 @@ function settlingAt(settledAt: Map): SettleRun { definition_version: 1, status: 'succeeded', started_at: '2026-10-07T09:00:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, }; }); } diff --git a/packages/workflow-host/src/folder-testing/projected-folders.test.ts b/packages/workflow-host/src/folder-testing/projected-folders.test.ts index bb27be936..cc9762889 100644 --- a/packages/workflow-host/src/folder-testing/projected-folders.test.ts +++ b/packages/workflow-host/src/folder-testing/projected-folders.test.ts @@ -32,6 +32,8 @@ function ranBy(runId: string) { }; } +const started = { type: 'run_started', data: { started_by: { id: 'acme-admin', name: 'acme-admin' }, input: {} } }; + function succeeded(output: number) { return { type: 'run_succeeded', data: { output, record: {} } }; } @@ -46,6 +48,7 @@ async function projected() { type: 'definition_created', data: { content: { source: 'runs', details: { counts: 'run_succeeded' } }, + created_by: { id: 'acme-admin', name: 'acme-admin' }, }, }, ], @@ -65,11 +68,7 @@ describe('the folders a projector keeps, as a test or a measurement opens them', it('fold what is appended to a store of their own tables, once a projector is started, until it is stopped', async () => { const folders = await projected(); const read: string[] = []; - await folders.append( - `${alphaKey}runs/${firstRun}`, - [{ type: 'run_started', data: { input: {} } }, succeeded(1)], - ranBy(firstRun), - ); + await folders.append(`${alphaKey}runs/${firstRun}`, [started, succeeded(1)], ranBy(firstRun)); folders.start({ through: (database) => ({ @@ -81,11 +80,7 @@ describe('the folders a projector keeps, as a test or a measurement opens them', }), }); await untilCounted(folders, 1); - await folders.append( - `${alphaKey}runs/${secondRun}`, - [{ type: 'run_started', data: { input: {} } }, succeeded(2)], - ranBy(secondRun), - ); + await folders.append(`${alphaKey}runs/${secondRun}`, [started, succeeded(2)], ranBy(secondRun)); await untilCounted(folders, 2); await folders.stop(); @@ -98,11 +93,7 @@ describe('the folders a projector keeps, as a test or a measurement opens them', folders.start({ sweepEveryMs: 1000, pagesPerWake: 1, playbooksAtOnce: 1 }); await untilCounted(folders, 0); await folders.stop(); - await folders.append( - `${alphaKey}runs/${firstRun}`, - [{ type: 'run_started', data: { input: {} } }, succeeded(1)], - ranBy(firstRun), - ); + await folders.append(`${alphaKey}runs/${firstRun}`, [started, succeeded(1)], ranBy(firstRun)); const [row] = await Effect.runPromise(talliesOf(folders.database, alphaKey)); expect(row?.counted).toBe(0); diff --git a/packages/workflow-host/src/folder-testing/tally-harness.ts b/packages/workflow-host/src/folder-testing/tally-harness.ts index 1a8e12aa9..a766f4e67 100644 --- a/packages/workflow-host/src/folder-testing/tally-harness.ts +++ b/packages/workflow-host/src/folder-testing/tally-harness.ts @@ -76,9 +76,12 @@ export async function tallyHarness(settings: DatabaseSettings): Promise { const version = (versions.get(name) ?? 0) + 1; versions.set(name, version); - const type = version === 1 ? 'definition_created' : 'definition_updated'; const content = { source: `the ${name} tally, version ${version}`, details }; - await views.append(tallyStream, [{ type, data: { content } }], contextOf(name, version)); + const saving = + version === 1 + ? { type: 'definition_created', data: { content, created_by: { id: 'acme-admin', name: 'acme-admin' } } } + : { type: 'definition_updated', data: { content } }; + await views.append(tallyStream, [saving], contextOf(name, version)); }, retiredTally: async (name) => { await views.append( diff --git a/packages/workflow-host/src/follower/followed-events.test.ts b/packages/workflow-host/src/follower/followed-events.test.ts index 88a6a1ea0..7d105f142 100644 --- a/packages/workflow-host/src/follower/followed-events.test.ts +++ b/packages/workflow-host/src/follower/followed-events.test.ts @@ -45,7 +45,12 @@ function published(context: Context = byAdmin, correlationId: string | null = nu function runFact(runId: string, [type, name]: readonly [string, string], correlationId: string | null) { const context = { ...byAdmin, runId, definitionType: type, definitionName: name, definitionVersion: 1 }; return followedEventOf( - recordOf(`runs/${runId}`, { type: 'run_started', data: { input: {} } }, context, correlationId), + recordOf( + `runs/${runId}`, + { type: 'run_started', data: { started_by: { id: 'acme-admin', name: 'acme-admin' }, input: {} } }, + context, + correlationId, + ), 'workflow', ); } @@ -121,7 +126,10 @@ describe('a fact of a playbook that is none, or cannot be read, as the follower it('is unreadable when its record cannot be read, and no fact at all for a record that is none', () => { expect([ followedEventOf( - recordOf('runs/r-1', { type: 'run_started', data: { input: 7, calls_tools: false } }), + recordOf('runs/r-1', { + type: 'run_started', + data: { input: 7, calls_tools: false }, + }), 'workflow', ), followedEventOf(recordOf('runs/r-1', { type: 'call_recorded' }), 'workflow'), diff --git a/packages/workflow-host/src/follower/follower-records.test.ts b/packages/workflow-host/src/follower/follower-records.test.ts index 13dfda283..222766ab2 100644 --- a/packages/workflow-host/src/follower/follower-records.test.ts +++ b/packages/workflow-host/src/follower/follower-records.test.ts @@ -44,8 +44,14 @@ describe('a record of a playbook the follower cannot read', () => { ); await definitionRecorded(store, { name: 'watch', version: 1, triggers: [trigger] }); await recorded(store, `${alpha}events/bad`, { type: 'event_published', data: { event: 'not an event' } }); - await recorded(store, `${alpha}runs/r-bad`, { type: 'run_started', data: { input: {}, calls_tools: 7 } }); - await recorded(store, `${alpha}definitions/workflow`, { type: 'definition_created', data: { content: 7 } }); + await recorded(store, `${alpha}runs/r-bad`, { + type: 'run_started', + data: { input: {}, calls_tools: 7 }, + }); + await recorded(store, `${alpha}definitions/workflow`, { + type: 'definition_created', + data: { content: 7 }, + }); await published(store, { id: 's1', type: 'com.acme.sentinel' }); await until( diff --git a/packages/workflow-host/src/follower/pass-types.test.ts b/packages/workflow-host/src/follower/pass-types.test.ts index 6f36d895c..0f6538f13 100644 --- a/packages/workflow-host/src/follower/pass-types.test.ts +++ b/packages/workflow-host/src/follower/pass-types.test.ts @@ -25,7 +25,10 @@ function triggerActivatedAt(position: number): RecordedEvent { return { ...recordAt('definitions/workflow', position), type: 'definition_created', - data: { content: { source: 'schedule: {}', triggers: [eventTrigger({ type: 'run_succeeded' })] } }, + data: { + content: { source: 'schedule: {}', triggers: [eventTrigger({ type: 'run_succeeded' })] }, + created_by: { id: 'acme-admin', name: 'acme-admin' }, + }, context: { at, by: 'acme-admin', definitionType: 'workflow', definitionName: 'close', definitionVersion: 1 }, }; } diff --git a/packages/workflow-host/src/follower/playbook-discovery.test.ts b/packages/workflow-host/src/follower/playbook-discovery.test.ts index 73c984993..c2d5c4277 100644 --- a/packages/workflow-host/src/follower/playbook-discovery.test.ts +++ b/packages/workflow-host/src/follower/playbook-discovery.test.ts @@ -52,7 +52,7 @@ describe('a stream of an org whose name only ends as its registry’s does', () const { store } = await openedOn(settings); await recorded(store, 'org/acme/elsewhere/playbooks', { type: 'playbook_created', - data: { playbook: 'gamma', name: 'gamma', description: '' }, + data: { playbook: 'gamma', name: 'gamma', description: '', created_by: { id: 'acme-admin', name: 'acme-admin' } }, }); await playbookCreated(store, 'alpha'); await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed] }); diff --git a/packages/workflow-host/src/projector/playbook-definitions.test.ts b/packages/workflow-host/src/projector/playbook-definitions.test.ts index aa2b3c905..574093b70 100644 --- a/packages/workflow-host/src/projector/playbook-definitions.test.ts +++ b/packages/workflow-host/src/projector/playbook-definitions.test.ts @@ -9,7 +9,7 @@ const fold = 'export function fold(view: number): number { return view + 1; }'; function savedAs(name: string, content: object) { return { type: 'definition_created', - data: { content }, + data: { content, created_by: { id: 'acme-admin', name: 'acme-admin' } }, context: { by: 'acme-admin', at: '2026-10-06T09:00:00.000Z', diff --git a/packages/workflow-host/src/projector/projector-wake-up.test.ts b/packages/workflow-host/src/projector/projector-wake-up.test.ts index fa315655e..6924d880d 100644 --- a/packages/workflow-host/src/projector/projector-wake-up.test.ts +++ b/packages/workflow-host/src/projector/projector-wake-up.test.ts @@ -53,13 +53,21 @@ async function wokenProjector(): Promise { async function talliedRunOf({ append }: ProjectedFolders): Promise { await append( `${alpha}definitions/${tallyType}`, - [{ type: 'definition_created', data: { content: { source: 'runs', details: { counts: 'run_succeeded' } } } }], + [ + { + type: 'definition_created', + data: { + content: { source: 'runs', details: { counts: 'run_succeeded' } }, + created_by: { id: 'acme-admin', name: 'acme-admin' }, + }, + }, + ], { by: 'acme-admin', at: savedAt, definitionType: tallyType, definitionName: 'runs', definitionVersion: 1 }, ); await append( `${alpha}runs/${runId}`, [ - { type: 'run_started', data: { input: {} } }, + { type: 'run_started', data: { started_by: { id: 'acme-admin', name: 'acme-admin' }, input: {} } }, { type: 'run_succeeded', data: { output: 1, record: {} } }, ], { by: 'acme-admin', at: savedAt, definitionType: 'reasoning', definitionName: 'runs', definitionVersion: 1, runId }, diff --git a/packages/workflow-host/src/reaction-testing/playbook-writes.ts b/packages/workflow-host/src/reaction-testing/playbook-writes.ts index 414804edd..0e9431ca0 100644 --- a/packages/workflow-host/src/reaction-testing/playbook-writes.ts +++ b/packages/workflow-host/src/reaction-testing/playbook-writes.ts @@ -100,7 +100,9 @@ export function definitionRecorded( return recorded( store, `${playbookKey}definitions/workflow`, - { type: version === 1 ? 'definition_created' : 'definition_updated', data: { content } }, + version === 1 + ? { type: 'definition_created', data: { content, created_by: { id: 'acme-admin', name: 'acme-admin' } } } + : { type: 'definition_updated', data: { content } }, { ...byAdmin, at: when, definitionType: 'workflow', definitionName: name, definitionVersion: version }, ); } @@ -125,7 +127,7 @@ export function definitionRetired(store: LedgerStreams, name: string, version: n export function playbookCreated(store: LedgerStreams, playbook: string) { return recorded(store, playbooksStreamOfOrg('acme'), { type: 'playbook_created', - data: { playbook, name: playbook, description: '' }, + data: { playbook, name: playbook, description: '', created_by: { id: 'acme-admin', name: 'acme-admin' } }, }); } @@ -156,7 +158,12 @@ export async function runRecorded(store: LedgerStreams, run: RunOf, ending?: 'ru ...(depth === undefined ? {} : { depth }), }; const lineage = { causationId: null, correlationId: correlation }; - await recordedWith(store, stream, { type: 'run_started', data: { input: {} } }, { context, lineage }); + await recordedWith( + store, + stream, + { type: 'run_started', data: { started_by: { id: 'acme-admin', name: 'acme-admin' }, input: {} } }, + { context, lineage }, + ); if (ending !== undefined) { await recordedWith(store, stream, { type: ending, data: { output: 'done', record: {} } }, { context, lineage }); } diff --git a/packages/workflow-host/src/reactions/run-workflows.test.ts b/packages/workflow-host/src/reactions/run-workflows.test.ts index 8c480ee3d..3d53110c3 100644 --- a/packages/workflow-host/src/reactions/run-workflows.test.ts +++ b/packages/workflow-host/src/reactions/run-workflows.test.ts @@ -8,7 +8,7 @@ const playbookKey = 'playbook/acme/alpha/'; function startedRecord(type: string, name: string) { return { type: 'run_started', - data: { input: {} }, + data: { started_by: { id: 'acme-admin', name: 'acme-admin' }, input: {} }, context: { by: 'acme-admin', at: 'now', diff --git a/packages/workflow-host/src/settlement/settle-back-off.test.ts b/packages/workflow-host/src/settlement/settle-back-off.test.ts index 1cacbf452..7f659435a 100644 --- a/packages/workflow-host/src/settlement/settle-back-off.test.ts +++ b/packages/workflow-host/src/settlement/settle-back-off.test.ts @@ -27,7 +27,7 @@ const recordedRun = { definition_version: 1, status: 'succeeded', started_at: '2026-10-05T09:00:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, } as const; interface Recording { diff --git a/packages/workflow-host/src/testing/known-runs.ts b/packages/workflow-host/src/testing/known-runs.ts index a90521097..183f20beb 100644 --- a/packages/workflow-host/src/testing/known-runs.ts +++ b/packages/workflow-host/src/testing/known-runs.ts @@ -10,7 +10,7 @@ const settledRun: Run = { status: 'succeeded', output: 'done', started_at: '2026-10-01T09:00:00.000Z', - started_by: 'acme-admin', + started_by: { id: 'acme-admin', name: 'acme-admin' }, finished_at: '2026-10-01T09:00:01.000Z', }; diff --git a/packages/workflow-host/src/triggers/definition-records.test.ts b/packages/workflow-host/src/triggers/definition-records.test.ts index 8581b8159..3400495e4 100644 --- a/packages/workflow-host/src/triggers/definition-records.test.ts +++ b/packages/workflow-host/src/triggers/definition-records.test.ts @@ -44,12 +44,15 @@ function about(name: string, version: number, at: string) { function saved({ id, name, version, triggers, at = first }: Saved): DefinitionRecord { const content = triggers.length === 0 ? { source: 'do: []' } : { source: 'schedule: {}', triggers }; - return { - id, - type: version === 1 ? 'definition_created' : 'definition_updated', - data: { content }, - context: about(name, version, new Date(at).toISOString()), - }; + const context = about(name, version, new Date(at).toISOString()); + return version === 1 + ? { + id, + type: 'definition_created', + data: { content, created_by: { id: 'acme-admin', name: 'acme-admin' } }, + context, + } + : { id, type: 'definition_updated', data: { content }, context }; } function retired(id: string, name: string): DefinitionRecord { @@ -269,7 +272,7 @@ describe('the records of the definitions read back from their stream', () => { messages: [ { type: 'definition_created', - data: { content: { source: 'do: []' } }, + data: { content: { source: 'do: []' }, created_by: { id: 'acme-admin', name: 'acme-admin' } }, context, lineage: { id: 'm1', causationId: null, correlationId: null }, }, @@ -285,7 +288,7 @@ describe('the records of the definitions read back from their stream', () => { { id: 'm1', type: 'definition_created', - data: { content: { source: 'do: []' } }, + data: { content: { source: 'do: []' }, created_by: { id: 'acme-admin', name: 'acme-admin' } }, context, }, { id: 'm2', type: 'definition_retired', data: {}, context }, diff --git a/packages/workflow-host/src/triggers/trigger-rebuild.test.ts b/packages/workflow-host/src/triggers/trigger-rebuild.test.ts index eb97932d2..0cf9e6641 100644 --- a/packages/workflow-host/src/triggers/trigger-rebuild.test.ts +++ b/packages/workflow-host/src/triggers/trigger-rebuild.test.ts @@ -74,8 +74,14 @@ describe('the triggers of a playbook made again, past the records of its definit const settings = await onSQLite(); const { store } = await openedOn(settings); await playbookCreated(store, 'alpha'); - await recorded(store, `${alpha}definitions/workflow`, { type: 'definition_created', data: { content: 7 } }); - await recorded(store, `${alpha}definitions/workflow`, { type: 'definition_created', data: { content: {} } }); + await recorded(store, `${alpha}definitions/workflow`, { + type: 'definition_created', + data: { content: 7 }, + }); + await recorded(store, `${alpha}definitions/workflow`, { + type: 'definition_created', + data: { content: {} }, + }); await definitionRecorded(store, { name: 'tick', version: 1, triggers: [everyTrigger(aMinute)] }); const reacting = await reactingHost({ settings, clock: movedClock(activatedAt + 1000) }); diff --git a/packages/workflow-host/src/views-testing/playbook-appends.ts b/packages/workflow-host/src/views-testing/playbook-appends.ts index 7705cc790..50f164253 100644 --- a/packages/workflow-host/src/views-testing/playbook-appends.ts +++ b/packages/workflow-host/src/views-testing/playbook-appends.ts @@ -53,7 +53,7 @@ function ranBy(append: Append): Ran { await append( `${streamPrefixOfPlaybook(playbook)}runs/${id}`, [ - { type: 'run_started', data: { input: {} } }, + { type: 'run_started', data: { started_by: { id: 'acme-admin', name: 'acme-admin' }, input: {} } }, { type: 'run_succeeded', data: { output, record: {} } }, ], { ...ranContext(subject, at), runId: id }, @@ -67,12 +67,15 @@ function savedBy(append: Append, definitions: Map): PlaybookAppe const key = `${streamPrefixOfPlaybook(playbook)}${name}`; const version = (definitions.get(key) ?? 0) + 1; definitions.set(key, version); - const type = version === 1 ? 'definition_created' : 'definition_updated'; const content = { source: `the ${name} document, version ${version}`, ...(details === undefined ? {} : { details }), }; - await append(`${streamPrefixOfPlaybook(playbook)}definitions/recall`, [{ type, data: { content } }], { + const saving = + version === 1 + ? { type: 'definition_created', data: { content, created_by: { id: 'acme-admin', name: 'acme-admin' } } } + : { type: 'definition_updated', data: { content } }; + await append(`${streamPrefixOfPlaybook(playbook)}definitions/recall`, [saving], { by: 'acme-admin', at: savedAt, definitionType: 'recall', diff --git a/packages/workflow-host/src/waiting-testing/lost-run.ts b/packages/workflow-host/src/waiting-testing/lost-run.ts index 4549b2357..584bbfb5a 100644 --- a/packages/workflow-host/src/waiting-testing/lost-run.ts +++ b/packages/workflow-host/src/waiting-testing/lost-run.ts @@ -29,7 +29,10 @@ export async function startedThenCancelled(database: HostDatabase): Promise { describe('the endings of the runs a run waits for, before a timer of that run fires', () => { it('are delivered first, so a child that ended while the server was down answers before the deadline', async () => { const { database, callStates } = await waitingParent(child); - await recorded(database.store, `${alpha}runs/${child}`, { type: 'run_started', data: { input: {} } }, ofTheChild); + await recorded( + database.store, + `${alpha}runs/${child}`, + { type: 'run_started', data: { started_by: { id: 'acme-admin', name: 'acme-admin' }, input: {} } }, + ofTheChild, + ); const submitted: unknown[] = []; const endedChildren = endedChildrenOn({ database, From f82f2a0cee9ff241e532bd53b06d663fdb96f773 Mon Sep 17 00:00:00 2001 From: Rami Date: Mon, 26 Oct 2026 19:23:43 +0000 Subject: [PATCH 2/2] ci(global): the smoke test reads who started a run by id and name Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eafd137ea..a8b1cf26b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1082,7 +1082,7 @@ jobs: "$playbook/events")" = 200 ended_runs closing 2 150 test "$(jq --compact-output '[.runs[] | [.status, .started_by]]' runs.json)" = \ - '[["succeeded","playbook:smoke"],["succeeded","playbook:smoke"]]' + '[["succeeded",{"id":"playbook:smoke","name":"smoke"}],["succeeded",{"id":"playbook:smoke","name":"smoke"}]]' for run in $(jq --raw-output '.runs[].run_id' runs.json); do test "$(call "history-$run.json" "$playbook/runs/$run/history")" = 200 test "$(call "run-$run.json" "$playbook/runs/$run")" = 200