@gjermundgaraba/effect-actions 0.18.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +20 -47
  2. package/dist/Action.d.mts +96 -49
  3. package/dist/Action.mjs +12 -16
  4. package/dist/ActionCli.d.mts +82 -44
  5. package/dist/ActionCli.mjs +54 -35
  6. package/dist/ActionHttp.d.mts +84 -45
  7. package/dist/ActionHttp.mjs +3 -4
  8. package/dist/ActionMcp.d.mts +54 -29
  9. package/dist/ActionMcp.mjs +33 -15
  10. package/dist/ActionRpc.d.mts +48 -37
  11. package/dist/ActionRpc.mjs +3 -4
  12. package/dist/ActionToolkit.d.mts +32 -15
  13. package/dist/ActionToolkit.mjs +11 -9
  14. package/dist/Authentication.d.mts +92 -42
  15. package/dist/Authentication.mjs +38 -37
  16. package/dist/Testing.d.mts +23 -27
  17. package/dist/Testing.mjs +1 -2
  18. package/dist/authentication/provider.d.mts +1 -6
  19. package/dist/authentication/provider.mjs +2 -3
  20. package/dist/authentication/refusal.mjs +3 -3
  21. package/dist/cli/commands.d.mts +34 -13
  22. package/dist/cli/commands.mjs +48 -24
  23. package/dist/contract/ast.mjs +1 -2
  24. package/dist/contract/call.d.mts +1 -3
  25. package/dist/contract/call.mjs +1 -2
  26. package/dist/contract/caller.d.mts +1 -3
  27. package/dist/contract/caller.mjs +2 -3
  28. package/dist/contract/declared.d.mts +0 -2
  29. package/dist/contract/declared.mjs +28 -16
  30. package/dist/contract/errors.d.mts +42 -19
  31. package/dist/contract/errors.mjs +43 -19
  32. package/dist/contract/hints.mjs +4 -4
  33. package/dist/contract/implementation.d.mts +7 -14
  34. package/dist/contract/implementation.mjs +31 -19
  35. package/dist/contract/media.d.mts +7 -5
  36. package/dist/contract/media.mjs +7 -4
  37. package/dist/contract/names.mjs +15 -14
  38. package/dist/contract/status.mjs +4 -12
  39. package/dist/http/client.d.mts +15 -6
  40. package/dist/http/client.mjs +2 -3
  41. package/dist/http/schema-errors.mjs +1 -2
  42. package/dist/mcp/client.d.mts +21 -10
  43. package/dist/mcp/client.mjs +8 -11
  44. package/dist/mcp/protocol.mjs +1 -2
  45. package/dist/stdio/console.mjs +1 -2
  46. package/dist/testing/memory.d.mts +1 -3
  47. package/dist/testing/memory.mjs +1 -2
  48. package/docs/Action-failures.md +6 -15
  49. package/docs/Action.md +159 -191
  50. package/docs/ActionCli-failures.md +12 -92
  51. package/docs/ActionCli.md +102 -156
  52. package/docs/ActionHttp-failures.md +14 -76
  53. package/docs/ActionHttp.md +150 -176
  54. package/docs/ActionMcp-failures.md +2 -22
  55. package/docs/ActionMcp.md +79 -170
  56. package/docs/ActionRpc-failures.md +3 -12
  57. package/docs/ActionRpc.md +183 -120
  58. package/docs/ActionToolkit-failures.md +3 -10
  59. package/docs/ActionToolkit.md +112 -109
  60. package/docs/Authentication-advanced.md +171 -108
  61. package/docs/Authentication-failures.md +11 -91
  62. package/docs/Authentication.md +114 -135
  63. package/docs/README.md +45 -30
  64. package/docs/Testing-failures.md +2 -6
  65. package/docs/Testing.md +126 -159
  66. package/docs/setup-failures.md +8 -32
  67. package/docs/setup.md +42 -38
  68. package/examples/README.md +6 -10
  69. package/examples/assistant/README.md +20 -38
  70. package/examples/assistant/src/agent.ts +2 -3
  71. package/examples/assistant/src/cli.ts +4 -9
  72. package/examples/assistant/src/commands.ts +6 -4
  73. package/examples/assistant/src/identity/authorize.ts +3 -3
  74. package/examples/assistant/src/tasks/Tasks.ts +1 -2
  75. package/examples/assistant/src/tasks/handlers.ts +3 -3
  76. package/examples/assistant/src/toolkit.ts +6 -8
  77. package/examples/assistant/test/commands.test.ts +16 -11
  78. package/examples/assistant/test/tasks/tasks.test.ts +0 -2
  79. package/examples/assistant/test/toolkit.test.ts +0 -1
  80. package/examples/kitchen-sink/README.md +30 -52
  81. package/examples/kitchen-sink/src/Actions.ts +2 -3
  82. package/examples/kitchen-sink/src/Api.ts +3 -4
  83. package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
  84. package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
  85. package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
  86. package/examples/kitchen-sink/src/agent.ts +1 -1
  87. package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
  88. package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
  89. package/examples/kitchen-sink/src/attachments/download.ts +5 -8
  90. package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
  91. package/examples/kitchen-sink/src/browser.ts +3 -4
  92. package/examples/kitchen-sink/src/cli.ts +6 -7
  93. package/examples/kitchen-sink/src/config.ts +2 -0
  94. package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
  95. package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
  96. package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
  97. package/examples/kitchen-sink/src/identity/quota.ts +1 -1
  98. package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
  99. package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
  100. package/examples/kitchen-sink/src/issues/guide.ts +2 -3
  101. package/examples/kitchen-sink/src/ops.ts +3 -6
  102. package/examples/kitchen-sink/src/request-policy.ts +8 -4
  103. package/examples/kitchen-sink/src/routes.ts +4 -6
  104. package/examples/kitchen-sink/src/seed.ts +0 -1
  105. package/examples/kitchen-sink/src/server.ts +11 -12
  106. package/examples/kitchen-sink/src/stdio.ts +3 -2
  107. package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
  108. package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
  109. package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
  110. package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
  111. package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
  112. package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
  113. package/examples/kitchen-sink/test/request-policy.test.ts +39 -3
  114. package/examples/kitchen-sink/test/routes.test.ts +3 -3
  115. package/examples/live-rpc/README.md +8 -49
  116. package/examples/live-rpc/src/browser.ts +8 -41
  117. package/examples/live-rpc/src/chat/Chat.ts +9 -3
  118. package/examples/live-rpc/src/chat/room.ts +1 -1
  119. package/examples/live-rpc/src/client.ts +2 -3
  120. package/examples/live-rpc/src/page.ts +34 -0
  121. package/examples/live-rpc/src/routes.ts +6 -7
  122. package/examples/live-rpc/src/server.ts +6 -4
  123. package/examples/live-rpc/test/browser.test.ts +6 -5
  124. package/examples/live-rpc/test/routes.test.ts +16 -15
  125. package/examples/live-rpc/test/server.test.ts +1 -1
  126. package/examples/mcp-tools/README.md +22 -45
  127. package/examples/mcp-tools/src/printers/Printers.ts +3 -7
  128. package/examples/mcp-tools/src/routes.ts +0 -7
  129. package/examples/mcp-tools/src/server.ts +1 -1
  130. package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
  131. package/examples/mcp-tools/test/routes.test.ts +2 -3
  132. package/examples/notes-service/README.md +9 -43
  133. package/examples/notes-service/src/cli.ts +0 -3
  134. package/examples/notes-service/src/commands.ts +7 -17
  135. package/examples/notes-service/src/identity/authorize.ts +2 -5
  136. package/examples/notes-service/src/identity/verifier.ts +3 -4
  137. package/examples/notes-service/src/notes/Notes.ts +2 -6
  138. package/examples/notes-service/src/notes/store.ts +5 -1
  139. package/examples/notes-service/src/routes.ts +6 -8
  140. package/examples/notes-service/src/server.ts +4 -3
  141. package/examples/notes-service/src/stdio.ts +4 -3
  142. package/examples/notes-service/test/commands.test.ts +1 -1
  143. package/examples/notes-service/test/notes/as.ts +6 -0
  144. package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
  145. package/examples/notes-service/test/notes/in-process.test.ts +50 -0
  146. package/examples/notes-service/test/notes/notes.test.ts +3 -42
  147. package/examples/notes-service/test/routes.test.ts +2 -2
  148. package/examples/quickstart/README.md +1 -1
  149. package/examples/quickstart/src/client.ts +1 -0
  150. package/examples/quickstart/src/server.ts +2 -4
  151. package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
  152. package/package.json +12 -16
  153. package/docs/Action-in-process.md +0 -93
  154. package/docs/CONTEXT.md +0 -46
  155. package/docs/guarantees.md +0 -44
@@ -4,10 +4,6 @@ An application with no server: its actions run in the process that calls them. A
4
4
  list is worked on by a model, through tools, and by whoever runs the machine, through commands.
5
5
  Both are the same implementations behind the same authorization. It opens no port.
6
6
 
7
- Start here if a model calls your code in process (an agent, a chat bot, a job), or if you are
8
- building a local command-line tool. For a service others call over the network, start from
9
- [notes-service](../notes-service/).
10
-
11
7
  ## Run it
12
8
 
13
9
  From the repository root, after `vp install` (from an installed package, see
@@ -28,9 +24,7 @@ vp test examples/assistant
28
24
 
29
25
  The agent and each command are separate processes. They share one JSON file, named by
30
26
  `ASSISTANT_TASKS` and kept in the system's temporary directory by default. Running the agent
31
- again adds the same tasks again: delete the file to start over. The model is scripted, the
32
- members and their permissions are fixed tables, and nothing locks the file between processes:
33
- replace all three before relying on any of it.
27
+ again adds the same tasks again: delete the file to start over.
34
28
 
35
29
  ## Layout
36
30
 
@@ -61,36 +55,24 @@ test/
61
55
  └── commands.test.ts the commands run in memory: syntax and output
62
56
  ```
63
57
 
64
- - **Two surfaces, no transport.** `toolkit.ts` and `commands.ts` take the same implementations.
65
- Neither holds a caller or a store: `agent.ts` and `cli.ts` provide those, and so does a test.
66
- - **The caller comes from the host, per call.** Nothing here authenticates anyone. `agent.ts`
67
- provides the member around each turn; `cli.ts` provides the operator on the command. A caller
68
- is never given to the toolkit's `layer`, which is built once for every member.
69
- - **The operator is a type, not a flag.** `Member | Operator` is what an action may be called
70
- as. Only `cli.ts` builds an `Operator`, and the demo team is typed as members, so no sign-in
71
- produces one. A model's caller is named through `actingFor`, which takes a `Member`. The tools
72
- themselves accept either: keeping the operator away from a model is the host's doing.
73
- - **Approval is not authorization.** `needsApproval` decides which of a model's calls wait for a
74
- person. `authorize` decides who may call at all, and runs for an approved call too.
75
-
76
58
  ## Where to look
77
59
 
78
- | To see | Read |
79
- | -------------------------------------------------------------------- | -------------------------------------------------- |
80
- | Implementations as a native Effect AI `Toolkit` | `src/toolkit.ts` |
81
- | Native tool titles in the approval display | `src/toolkit.ts`, `src/agent.ts` |
82
- | One approval rule reading the call and its caller | `src/toolkit.ts` |
83
- | A turn run as a member, and an approval answered through a `Chat` | `src/agent.ts` |
84
- | Where a real `LanguageModel` plugs in | `src/agent.ts`, `src/scripted.ts` |
85
- | Local commands with `positional`, `aliases`, `render`, `stdin` | `src/commands.ts` |
86
- | Services provided on the command, and the host naming the caller | `src/cli.ts` |
87
- | A command's result alone on stdout, its handler's logs on stderr | `src/cli.ts`, `src/platform/tasks-file.ts` |
88
- | `logToStderr` moving a defect's report from stdout to stderr | `src/cli.ts` |
89
- | An identity only the host builds: the trusted operator | `src/identity/Identity.ts` |
90
- | An authorizer built once from a service, reading the caller per call | `src/identity/authorize.ts` |
91
- | One service with two Layers, chosen by the entrypoint | `src/tasks/store.ts`, `src/platform/tasks-file.ts` |
92
- | Changes serialized, since a turn's tool calls run concurrently | `src/tasks/store.ts` |
93
- | Testing handlers and authorization, each caller around its calls | `test/tasks/tasks.test.ts` |
94
- | Testing a Layer over a temporary file, its path set through config | `test/platform/tasks-file.test.ts` |
95
- | Testing a tool call, an approval required, an approval answered | `test/toolkit.test.ts` |
96
- | Testing a command's syntax and output without a process | `test/commands.test.ts` |
60
+ | To see | Read |
61
+ | --------------------------------------------------------------------- | -------------------------------------------------- |
62
+ | Implementations as a native Effect AI `Toolkit` | `src/toolkit.ts` |
63
+ | Native tool titles in the approval display | `src/toolkit.ts`, `src/agent.ts` |
64
+ | One approval rule reading the call and its caller | `src/toolkit.ts` |
65
+ | A turn run as a member, and an approval answered through a `Chat` | `src/agent.ts` |
66
+ | Where a real `LanguageModel` plugs in | `src/agent.ts`, `src/scripted.ts` |
67
+ | Local commands with `positional`, `aliases`, `render`, `stdin` | `src/commands.ts` |
68
+ | Services provided on the command, and the host naming the caller | `src/cli.ts` |
69
+ | A command's result alone on stdout, its handler's logs on stderr | `src/cli.ts`, `src/platform/tasks-file.ts` |
70
+ | `logToStderr` moving a defect's report from stdout to stderr | `src/cli.ts` |
71
+ | An identity only the host builds: the trusted operator | `src/identity/Identity.ts` |
72
+ | An authorizer built once from a service, reading the caller per call | `src/identity/authorize.ts` |
73
+ | One service with two Layers: a file for entrypoints, memory for tests | `src/tasks/store.ts`, `src/platform/tasks-file.ts` |
74
+ | Changes serialized, since a turn's tool calls run concurrently | `src/tasks/store.ts` |
75
+ | Testing handlers and authorization, each caller around its calls | `test/tasks/tasks.test.ts` |
76
+ | Testing a Layer over a temporary file, its path set through config | `test/platform/tasks-file.test.ts` |
77
+ | Testing a tool call, an approval required, an approval answered | `test/toolkit.test.ts` |
78
+ | Testing a command's syntax and output without a process | `test/commands.test.ts` |
@@ -15,8 +15,7 @@ import { actingFor, layer, toolkit } from "./toolkit.js";
15
15
  // What a turn answers with: its text, the calls the model made and what they returned.
16
16
  type Response = LanguageModel.GenerateTextResponse<typeof toolkit.tools, "opaque">;
17
17
 
18
- // Printing only. A refusal or a declared error is a result too: JSON the model reads and may
19
- // act on.
18
+ // Printing only.
20
19
  const report = (member: Member) => (response: Response) => {
21
20
  const waiting = response.content.flatMap((part) =>
22
21
  part.type === "tool-approval-request" ? [part.toolCallId] : [],
@@ -89,7 +88,7 @@ conversations.pipe(
89
88
  // A real `LanguageModel` plugs in here: the layer of a provider's model, with the layer of
90
89
  // that provider's client, in place of the scripted one. Nothing below this line changes.
91
90
  Effect.provide(model),
92
- // Startup services only: where tasks and permissions are kept. Never a caller.
91
+ // Startup services only: where tasks and permissions are kept.
93
92
  Effect.provide(layer.pipe(Layer.provide([tasksFile, Permissions.layerMemory]))),
94
93
  Effect.provide(NodeServices.layer),
95
94
  NodeRuntime.runMain,
@@ -11,22 +11,17 @@ import { tasksFile } from "./platform/tasks-file.js";
11
11
  // Entrypoint: `node --import tsx examples/assistant/src/cli.ts list-tasks`.
12
12
  // The operator's own tool. No credential arrives at a terminal, so this host names the caller
13
13
  // itself: whoever runs the command, as the trusted operator. Being local earns nothing more:
14
- // `authorize` runs here as for a model's tool call, and admits the operator by name.
14
+ // `authorize` runs here as for a model's tool call, and admits the operator by its kind.
15
15
  const operator: Operator = { kind: "operator", id: userInfo().username };
16
16
 
17
17
  const assistant = commands.pipe(
18
- // Services go on the command, so they are built when an action runs: `--help` and a
19
- // mistyped flag never open the tasks file.
20
18
  Command.provide(Layer.mergeAll(tasksFile, Permissions.layerMemory)),
21
19
  Command.provideSync(CurrentCaller, operator),
22
20
  );
23
21
 
24
- // A command writes its result alone to stdout: JSON, or what `render` returns. Its failure
25
- // goes to stderr under an `ERROR` heading, as JSON such as `{"_tag":"TaskNotFound","id":"9"}`,
26
- // exiting 1, and so do its handler's logs, such as the store's. What happens outside the
27
- // command is `runMain`'s to report, on stdout: a defect, such as a tasks file that does not
28
- // decode. `logToStderr`, applied last, moves that to stderr too, so
29
- // `assistant list-tasks --json | jq` reads results.
22
+ // A command's defect, such as a tasks file that does not decode, `runMain` reports on stdout.
23
+ // `logToStderr`, applied last, moves that to stderr, so `assistant list-tasks --json | jq` reads
24
+ // results alone.
30
25
  Command.run(assistant, { version: "1.0.0" }).pipe(
31
26
  Effect.provide(NodeServices.layer),
32
27
  ActionCli.logToStderr,
@@ -2,15 +2,17 @@ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
2
2
  import { identity } from "./identity/handlers.js";
3
3
  import { tasks } from "./tasks/handlers.js";
4
4
 
5
- // The same implementations as local commands: `assistant <action>`, a subcommand per action in
6
- // kebab case, a flag per input field. A command runs the handler in this process, behind the
7
- // same `authorize`. `add-task` and `who-am-i` are as derived; the others each change one thing.
5
+ // The same implementations as local commands, `assistant <action>`: a command runs the handler
6
+ // in this process, behind the same `authorize`. `add-task` and `who-am-i` are as derived; the
7
+ // others each change their syntax or output.
8
8
  export const commands = ActionCli.make([tasks, identity], {
9
9
  name: "assistant",
10
10
  commands: {
11
11
  // `assistant list-tasks -a ada`: a short flag beside `--assignee`. `render` prints a line
12
- // per task for a person, and adds `--json` for the JSON a script reads.
12
+ // per task for a person, and adds `--json` for the JSON a script reads. `description` is the
13
+ // command's help, saying what a line holds; the action's stays what a model reads.
13
14
  listTasks: {
15
+ description: "Print one line per task: [x] when done, then its id, title and assignee.",
14
16
  aliases: { assignee: "a" },
15
17
  render: ({ tasks }) =>
16
18
  tasks
@@ -3,9 +3,9 @@ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
3
  import { CurrentCaller } from "./Identity.js";
4
4
  import { type Permission, Permissions } from "./permissions.js";
5
5
 
6
- // The one rule, for a model's tool call and for a command alike. It is built as handlers are:
7
- // this Effect runs once and gets the permission store, a startup service; the function it
8
- // returns runs before every handler and reads the caller of that call.
6
+ // The one rule, for a model's tool call and for a command alike. The permission store is a
7
+ // startup service, so this Effect gets it; the caller differs per call, so the function it
8
+ // returns reads it.
9
9
  export const authorize = Effect.gen(function* () {
10
10
  const permissions = yield* Permissions;
11
11
 
@@ -13,8 +13,7 @@ export const Task = Schema.Struct({
13
13
 
14
14
  export type Task = typeof Task.Type;
15
15
 
16
- // A declared error is part of the contract. A tool returns it to the model as a result it can
17
- // read, and a command prints it on stderr as JSON and exits 1.
16
+ // A declared error is part of the contract.
18
17
  export class TaskNotFound extends Schema.TaggedError<TaskNotFound>()("TaskNotFound", {
19
18
  id: Schema.String,
20
19
  }) {}
@@ -5,9 +5,9 @@ import { CurrentCaller } from "../identity/Identity.js";
5
5
  import { Tasks } from "./Tasks.js";
6
6
  import { TaskStore } from "./store.js";
7
7
 
8
- // The builder gets the store. It runs once for a toolkit's layer, however many calls a model
9
- // makes, and each time a command runs. `CurrentCaller` is read per call. No handler knows whether a model or a person at a terminal
10
- // called it, and none checks a permission: `authorize` has run before it.
8
+ // The builder gets the store. `CurrentCaller` is read per call. No handler knows whether a model
9
+ // or a person at a terminal called it, and none checks a permission: `authorize` has run before
10
+ // it.
11
11
  export const tasks = Action.implement(
12
12
  Tasks,
13
13
  Effect.gen(function* () {
@@ -5,14 +5,12 @@ import { identity } from "./identity/handlers.js";
5
5
  import { CurrentCaller, type Member } from "./identity/Identity.js";
6
6
  import { tasks } from "./tasks/handlers.js";
7
7
 
8
- // The implementations as a native Effect AI Toolkit: a tool per action, run in this process.
9
- // `toolkit` goes to `LanguageModel.generateText`; `layer` holds the handlers, and requires the
10
- // startup services their builders read. Neither holds a caller.
8
+ // `actions.layer` holds the handlers, and requires the startup services their builders read;
9
+ // neither it nor `actions.toolkit` holds a caller.
11
10
  const actions = ActionToolkit.make([tasks, identity], {
12
11
  // One rule over every call a model makes, asked before the call runs. A read runs at once. A
13
12
  // write waits for a person to approve it, except a task the caller adds to their own list:
14
13
  // `call.name` narrows `call.input` to that action's. Without a caller, it asks.
15
- // This is not authorization: an approved call still passes through `authorize`.
16
14
  needsApproval: (call) =>
17
15
  !call.action.readOnly &&
18
16
  Effect.map(
@@ -33,8 +31,8 @@ export const toolkit = Toolkit.merge(
33
31
 
34
32
  export const layer = actions.layer;
35
33
 
36
- // Who a turn runs as is provided around that turn, never to `layer`: one toolkit serves every
37
- // member. This is how this example names a model's caller, and it takes a `Member`. The tools
38
- // themselves accept any `CurrentCaller`, so keeping the operator away from a model is this
39
- // function's doing, and that of every host using it.
34
+ // One toolkit serves every member, so who a turn runs as is provided around that turn. This is
35
+ // how this example names a model's caller, and it takes a `Member`. The tools themselves accept
36
+ // any `CurrentCaller`, so keeping the operator away from a model is this function's doing, and
37
+ // that of every host using it.
40
38
  export const actingFor = (member: Member) => Effect.provideService(CurrentCaller, member);
@@ -15,16 +15,21 @@ const services = Layer.mergeAll(TaskStore.layerMemory, Permissions.layerMemory);
15
15
 
16
16
  const operator: Operator = { kind: "operator", id: "root" };
17
17
 
18
- // Effect's own runner, given arguments instead of the process's. `it.effect` provides a console
19
- // that keeps what is printed, and stdin is the text given here.
18
+ // Effect's own runner, given arguments instead of the process's, and stdin the text given here.
19
+ // The console of `it.effect` keeps every line printed, so a run returns the lines it added.
20
20
  const run = (args: ReadonlyArray<string>, stdin = "") =>
21
- Command.runWith(commands.pipe(Command.provideSync(CurrentCaller, operator)), { version: "0" })(
22
- args,
23
- ).pipe(
24
- Effect.andThen(TestConsole.logLines),
25
- Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })),
26
- Effect.provide(NodeServices.layer),
27
- );
21
+ Effect.gen(function* () {
22
+ const before = (yield* TestConsole.logLines).length;
23
+
24
+ yield* Command.runWith(commands.pipe(Command.provideSync(CurrentCaller, operator)), {
25
+ version: "0",
26
+ })(args).pipe(
27
+ Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })),
28
+ Effect.provide(NodeServices.layer),
29
+ );
30
+
31
+ return (yield* TestConsole.logLines).slice(before);
32
+ });
28
33
 
29
34
  describe("the commands", () => {
30
35
  it.effect("run as the operator the host names, taking an argument and stdin", () =>
@@ -47,11 +52,11 @@ describe("the commands", () => {
47
52
 
48
53
  const printed = yield* run(["list-tasks", "-a", "ada"]);
49
54
 
50
- expect(printed.at(-1)).toBe("[ ] 1 Rotate the keys (ada)");
55
+ expect(printed).toEqual(["[ ] 1 Rotate the keys (ada)"]);
51
56
 
52
57
  const json = yield* run(["who-am-i"]);
53
58
 
54
- expect(json.at(-1)).toBe(JSON.stringify({ id: "root", kind: "operator" }, undefined, 2));
59
+ expect(json).toEqual([JSON.stringify({ id: "root", kind: "operator" }, undefined, 2)]);
55
60
  }).pipe(Effect.provide(services)),
56
61
  );
57
62
  });
@@ -44,8 +44,6 @@ describe("tasks, in process", () => {
44
44
 
45
45
  const refusal = yield* Effect.flip(add.pipe(as(members.guest)));
46
46
 
47
- // `toEqual` on an error compares its fields and never its message, so the message is
48
- // asserted on its own.
49
47
  expect(refusal).toBeInstanceOf(Action.Forbidden);
50
48
  expect(refusal.message).toBe("Requires tasks:write.");
51
49
  expect(yield* client.listTasks().pipe(as(members.guest))).toEqual({ tasks: [] });
@@ -49,7 +49,6 @@ describe("the toolkit", () => {
49
49
 
50
50
  it.effect("asks for approval before a model adds to a list that is not its caller's", () =>
51
51
  Effect.gen(function* () {
52
- // `LanguageModel` is what enforces approval: a call by hand, as above, is never asked.
53
52
  // The model answers each member with the same two calls.
54
53
  const plan = (member: Member) =>
55
54
  LanguageModel.generateText({ prompt: "Plan the launch.", toolkit }).pipe(
@@ -5,10 +5,7 @@ and nearly every option. Members of a workspace file, discuss and close issues,
5
5
  screenshots, over HTTP, MCP, Effect RPC, a CLI and an assistant, in process and behind a route.
6
6
  Operators suspend workspaces on a second port, or from a console on the host.
7
7
 
8
- Open it when you need a working use of one feature: the [index](#index) maps each export and
9
- option to the file that shows it. To start an application, copy
10
- [notes-service](../notes-service/) instead; it is this layout at a size you can read in one
11
- sitting.
8
+ The [index](#index) maps each export and option to the file that shows it.
12
9
 
13
10
  ## Run it
14
11
 
@@ -42,17 +39,15 @@ subprocess. `sdk.ts` and `browser.ts` are imported by client code, not run. The
42
39
  document is at `/api/v1/openapi.json`, Swagger UI at `/docs`, the MCP endpoint at `/mcp`, the
43
40
  socket at `/rpc`.
44
41
 
45
- Everything is a demo: stores are memory, and the credentials are fixed strings. Replace both
46
- before deploying anything.
47
-
48
- | Credential | Who | May |
49
- | ------------------------------------- | ---------------------- | -------------------------------------- |
50
- | `Authorization: Bearer ada` | ada, in acme or globex | read and write in acme, read in globex |
51
- | `Authorization: Bearer linus` | linus, in acme | read |
52
- | `Authorization: Bearer grace` | grace, in globex | read and write |
53
- | `Cookie: tracker_session=ada-at-acme` | ada in acme, on `/rpc` | read and write |
54
- | `X-Operator-Key: ops-key` | operator `ops` | list, suspend and resume workspaces |
55
- | `X-Operator-Key: support-key` | operator `support` | list workspaces |
42
+ | Credential | Who | May |
43
+ | ----------------------------------------- | -------------------------- | -------------------------------------- |
44
+ | `Authorization: Bearer ada` | ada, in acme or globex | read and write in acme, read in globex |
45
+ | `Authorization: Bearer linus` | linus, in acme | read |
46
+ | `Authorization: Bearer grace` | grace, in globex | read and write |
47
+ | `Cookie: tracker_session=ada-at-acme` | ada in acme, on `/rpc` | read and write |
48
+ | `Cookie: tracker_session=grace-at-globex` | grace in globex, on `/rpc` | read and write |
49
+ | `X-Operator-Key: ops-key` | operator `ops` | list, suspend and resume workspaces |
50
+ | `X-Operator-Key: support-key` | operator `support` | list workspaces |
56
51
 
57
52
  A bearer request names its workspace in `X-Workspace: acme`. `kay` (token `kay`, session
58
53
  `kay-at-acme`) is a member of acme granted no scope yet. The session cookie also opens
@@ -125,34 +120,18 @@ test/
125
120
  └── admin/ the operators' grants; the trusted operator
126
121
  ```
127
122
 
128
- - **A folder per domain.** `identity/` and `admin/` are the two audiences; `workspaces/`,
129
- `issues/` and `attachments/` are what they work on. In each, the PascalCase file is the
130
- contract and imports no server code; the lowercase files run on the server.
131
- - **Top-level files are the host's.** `Actions.ts`, `Api.ts` and `Rpc.ts` say what is served
132
- where, and stay browser-safe. `routes.ts` builds the surfaces. Each entrypoint chooses the
133
- services, here from `memory.ts`. `server.ts` also chooses the rpcs' protocol, and a local
134
- one lists the implementations it serves itself.
135
- - **Three callers' credentials, three descriptors.** API clients, the CLI and MCP clients send
136
- a bearer token (`Login`). The web app sends its session cookie (`Session`) for the same
137
- identity, on its own binding. Operators send a key (`OperatorKey`) for another identity, on
138
- another port.
139
- - **Two limits, in two places.** The request quota is HTTP and RPC middleware
140
- (`identity/quota.ts`): it counts before the input is decoded. The write limit is the
141
- handlers' (`issues/write-limit.ts`): it holds on every surface, MCP and the assistant
142
- included.
143
-
144
123
  ## Where to look
145
124
 
146
- | To see | Read |
147
- | ----------------------------------------------------------------------- | ------------------------------------------------------- |
148
- | How one request is authenticated, placed in a tenant, authorized | `src/identity/authenticate.ts`, `src/routes.ts` |
149
- | Two audiences on two ports over one set of services | `src/server.ts`, `src/routes.ts`, `test/routes.test.ts` |
150
- | Which actions each surface serves, and why one is no tool | `src/Actions.ts` |
151
- | A limit before decoding and a limit in the handlers | `src/identity/quota.ts`, `src/issues/write-limit.ts` |
152
- | An image for a model and a client (an action), and for a page (a route) | `src/attachments/` |
153
- | A caller the host names itself | `src/stdio.ts`, `src/ops.ts`, `src/agent.ts` |
154
- | A model loop behind a route, approvals answered in a later request | `src/assistant/handlers.ts`, `src/routes.ts` |
155
- | What to test in process and what over the wire | `test/issues/issues.test.ts`, `test/routes.test.ts` |
125
+ | To see | Read |
126
+ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
127
+ | How one request is authenticated, placed in a tenant, authorized | `src/identity/authenticate.ts`, `src/workspaces/tenant.ts`, `src/identity/authorize.ts`, `src/routes.ts` |
128
+ | Two audiences on two ports over one set of services | `src/server.ts`, `src/routes.ts`, `test/routes.test.ts` |
129
+ | Which actions each surface serves, and why one is no tool | `src/Actions.ts` |
130
+ | A limit before decoding and a limit in the handlers | `src/identity/quota.ts`, `src/issues/write-limit.ts` |
131
+ | An image for a model and a client (an action), and for a page (a route) | `src/attachments/` |
132
+ | A caller the host names itself | `src/stdio.ts`, `src/ops.ts`, `src/agent.ts` |
133
+ | A model loop behind a route, approvals answered in a later request | `src/assistant/handlers.ts`, `src/routes.ts` |
134
+ | What to test in process and what over the wire | `test/issues/issues.test.ts`, `test/routes.test.ts` |
156
135
 
157
136
  ## Index
158
137
 
@@ -265,13 +244,13 @@ search for in the file.
265
244
 
266
245
  ### ActionCli
267
246
 
268
- | Export or option | File |
269
- | -------------------------------------------------------------- | ------------------------------------------ |
270
- | `remote`: `name`, `commands`, `client` | `src/cli.ts` |
271
- | `commands`: `stdin`, `positional`, `aliases`, `render`, `name` | `src/cli.ts` |
272
- | A client from `Config`, its credentials optional | `src/cli.ts` |
273
- | `make`, with a trusted identity the host supplies | `src/ops.ts` |
274
- | `logToStderr` | `src/cli.ts`, `src/ops.ts`, `src/stdio.ts` |
247
+ | Export or option | File |
248
+ | ----------------------------------------------------------------------------- | ------------------------------------------ |
249
+ | `remote`: `name`, `commands`, `client` | `src/cli.ts` |
250
+ | `commands`: `stdin`, `positional`, `aliases`, `render`, `name`, `description` | `src/cli.ts` |
251
+ | A client from `Config`, its credentials optional | `src/cli.ts` |
252
+ | `make`, with a trusted identity the host supplies | `src/ops.ts` |
253
+ | `logToStderr` | `src/cli.ts`, `src/ops.ts`, `src/stdio.ts` |
275
254
 
276
255
  ### Testing
277
256
 
@@ -304,8 +283,9 @@ Left out because this application has no honest place for them:
304
283
  [notes-service](../notes-service/) has two.
305
284
  - `ActionRpc.layer`'s `actions`, `Rpc.group`, and `ActionRpc.client`'s options.
306
285
  - A binding's other fields, `Http.actions`, `Http.error` and `Http.authentication`.
307
- - `ActionMcp`'s `path`, `description`, `websiteUrl`, `icons` and `extensions`; `fetchClient`'s
308
- `fetch`.
286
+ - `ActionMcp`'s `path`, `description`, `websiteUrl`, `icons`, `extensions`, `allowSubscriptions`
287
+ and `requestScoped` ([notes-service](../notes-service/) has it), and `McpCallError`;
288
+ `fetchClient`'s `fetch`.
309
289
  - `needsApproval` as an Effect reading the caller: the rule here is a boolean of the action.
310
290
  - `Action.InvalidInput.fromSchemaError`, the `Action.Refusal` and `Action.BuiltIn` schemas, and the
311
291
  helper types: `Action.Issue`, `Errors`, `Handlers`, `Client`, `AnyImplementation`,
@@ -314,5 +294,3 @@ Left out because this application has no honest place for them:
314
294
  `McpSchema.McpRequestContext`.
315
295
  - A tenant taken from a subdomain. The workspace is a header, so the OAuth resource the server
316
296
  publishes is one fixed URL.
317
- - A second way to the attachment's bytes for token holders: the download route takes the web
318
- app's cookie alone, and an API client reads the image through `getAttachment`.
@@ -12,8 +12,7 @@ export const MemberActions = [WhoAmI, ...Issues, AttachImage, GetAttachment, ...
12
12
 
13
13
  /**
14
14
  * The MCP tools, for the HTTP endpoint and the stdio server alike. `attachImage` is left out:
15
- * a model should not carry an image's bytes as an argument. So is the assistant: it is a model
16
- * itself, and its tools are already here. An action added to a domain later is no tool until
17
- * it is listed here.
15
+ * listing it throws, since MCP takes no image as input. So is the assistant: it is a model
16
+ * itself, and its tools are already here.
18
17
  */
19
18
  export const Tools = [GetStatus, WhoAmI, ...Issues, GetAttachment] as const;
@@ -7,16 +7,15 @@ import { GetStatus } from "./workspaces/Workspaces.js";
7
7
  // The members' HTTP binding: every action at `POST /api/v1/<name>`. It is plain data and
8
8
  // imports no server code, so the server, the CLI, the SDK and a test all import this one value.
9
9
  // - `authentication`: how the protected actions' caller proves who it is. `getStatus` is public.
10
- // - `error`: what middleware around the routes may answer with, on any endpoint. Every endpoint
11
- // declares it, so a client decodes a 429 as a typed `QuotaExceeded`.
10
+ // - `error`: the quota middleware's, so a client decodes its 429 as a typed `QuotaExceeded`.
12
11
  export const Http = ActionHttp.make([GetStatus, ...MemberActions], {
13
12
  prefix: "/api/v1",
14
13
  authentication: Login,
15
14
  error: QuotaExceeded,
16
15
  });
17
16
 
18
- // The operators' binding: other actions, another identity, another descriptor. One binding has
19
- // one identity, so a second audience is a second binding, here served on a port of its own.
17
+ // The operators' binding: other actions, another identity, another descriptor, served on a port
18
+ // of its own.
20
19
  // The public `getStatus` is bound here too: one contract, one implementation, on both ports.
21
20
  export const AdminHttp = ActionHttp.make([GetStatus, ...Admin], {
22
21
  prefix: "/admin",
@@ -10,7 +10,7 @@ export interface KeyOperator {
10
10
  readonly trusted: false;
11
11
  }
12
12
 
13
- /** What only a host supplies, on its own console. No verifier returns one, so no key names it. */
13
+ /** What only a host supplies, on its own console. */
14
14
  export interface TrustedOperator {
15
15
  readonly id: string;
16
16
  readonly trusted: true;
@@ -4,9 +4,9 @@ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
4
4
  import { type KeyOperator, OperatorKey } from "./Admin.js";
5
5
  import { keys } from "./keys.js";
6
6
 
7
- // Typed to return a `KeyOperator`, never the `Operator` union: whatever a key maps to, it
8
- // cannot be the trusted operator. The verifier receives what its scheme decodes, the header's
9
- // value, and needs no startup service, so it is passed as it is.
7
+ // Typed to return a `KeyOperator`, never the `Operator` union (Admin.ts says why). The verifier
8
+ // receives what its scheme decodes, the header's value, and needs no startup service, so it is
9
+ // passed as it is.
10
10
  const verify = (
11
11
  key: Redacted.Redacted<string>,
12
12
  ): Effect.Effect<KeyOperator, Action.Unauthenticated> =>
@@ -5,8 +5,7 @@ import { type Grant, Grants } from "./grants.js";
5
5
 
6
6
  // An authorizer built from a startup service, as a handler builder builds handlers: the
7
7
  // Effect yields `Grants` once, when the implementation is built, and the function it returns
8
- // reads the operator on every call. Written as a plain function yielding `Grants` per call, it
9
- // would make `Grants` a request-time requirement of every surface.
8
+ // reads the operator on every call.
10
9
  export const authorize = Effect.gen(function* () {
11
10
  const grants = yield* Grants;
12
11
 
@@ -38,8 +38,8 @@ const program = Effect.gen(function* () {
38
38
  program.pipe(
39
39
  Effect.scoped,
40
40
  // No remote caller: this host names the member the assistant acts for, around the turn.
41
+ // DEMO ONLY: a real host names whoever asked.
41
42
  Effect.provideService(CurrentMember, members.ada),
42
- // Startup services only. An identity provided here would be every caller's.
43
43
  Effect.provide(layer.pipe(Layer.provideMerge(Layer.merge(IssueStore.layerMemory, limiter)))),
44
44
  // DEMO ONLY: a scripted model. A real one is a provider's layer, in its place.
45
45
  Effect.provide(demoModel),
@@ -56,8 +56,6 @@ export const assistant = Action.implement(
56
56
  return {
57
57
  conversation,
58
58
  text: response.text,
59
- // A refusal or a declared error is a result too, which the model has read. So is
60
- // Effect's result for a call that did not run: left out.
61
59
  ran: results
62
60
  .filter(({ result }) => !didNotRun(result))
63
61
  .map(({ name, isFailure }) => ({ tool: name, failed: isFailure })),
@@ -78,10 +76,7 @@ export const assistant = Action.implement(
78
76
  Effect.provideService(LanguageModel.LanguageModel, model),
79
77
  );
80
78
 
81
- // If the model fails after an approved call, the call has run and the `Chat` has recorded
82
- // nothing of the turn: the same answer sent again would run the call again. So
83
- // `Conversations` ends the conversation of a turn that fails, and the error says what
84
- // may have happened.
79
+ // A failed turn says what may have run.
85
80
  const turn = (conversation: string, prompt: Prompt.RawInput) =>
86
81
  Effect.flatMap(CurrentMember, (member) =>
87
82
  conversations.turn(member, conversation, (chat) => converse(conversation, chat, prompt)),
@@ -19,23 +19,19 @@ export class AttachmentNotFound extends Schema.TaggedError<AttachmentNotFound>()
19
19
  { httpApiStatus: 404 },
20
20
  ) {}
21
21
 
22
- // `Action.Image` is `{ data: Uint8Array, mimeType: string }`. In an input it is JSON on every
23
- // surface, the bytes in base64. This action is no MCP tool (see `Tools` in Actions.ts): a tool
24
- // call passes its arguments through the model's context, and MCP takes no image as input.
22
+ // `Action.Image` is `{ data: Uint8Array, mimeType: string }`. In an input it is JSON over HTTP,
23
+ // RPC and the CLI, the bytes in base64.
25
24
  export const AttachImage = Action.make("attachImage", {
26
25
  description: "Attach an image, such as a screenshot, to an issue.",
27
26
  input: { issue: Schema.String, name: Schema.String, image: Action.Image },
28
27
  success: Attachment,
29
- // A write to an issue as a comment is: the errors those declare, the write limit's included.
30
28
  error: IssueWriteErrors,
31
29
  readOnly: false,
32
30
  caller: CurrentMember,
33
31
  });
34
32
 
35
- // `Action.Image` as a top-level field of the success: an MCP tool lifts it out of the result
36
- // into an image block the model sees as an image, beside the JSON of `{ attachment }`. HTTP,
37
- // RPC and the CLI send it as JSON. A model never reads a media field's own description, so the
38
- // action's description says what the image is.
33
+ // `Action.Image` as a top-level field of the success, so an MCP tool shows the model the image
34
+ // itself, beside the JSON of `{ attachment }`.
39
35
  export const GetAttachment = Action.make("getAttachment", {
40
36
  description: "Fetch an attachment: its name and size, and the image itself.",
41
37
  input: { id: Schema.String },
@@ -7,26 +7,23 @@ import { GetAttachment } from "./Attachments.js";
7
7
  import { AttachmentStore } from "./store.js";
8
8
 
9
9
  // A route of the host's own: `GET /attachments/<id>` answers the image's bytes, for an `<img>`
10
- // of the web app, which no action can (an action answers JSON). A descriptor covers action
11
- // routes alone, so this one takes `Authentication.protect(Session)`: router middleware that
12
- // verifies the page's cookie with the provider the web app's rpcs use, provides
13
- // `CurrentMember`, and answers a refusal the route fails with as an action's is answered. A
14
- // token holder has no use for it: `getAttachment` gives an API client the same image.
10
+ // of the web app, which no action can (an action answers JSON), behind
11
+ // `Authentication.protect(Session)`: the page's cookie, which the web app's rpcs use. A token
12
+ // holder has no use for it: `getAttachment` gives an API client the same image.
15
13
  export const download = Layer.unwrap(
16
14
  Effect.map(AttachmentStore, (store) =>
17
15
  HttpRouter.add(
18
16
  "GET",
19
17
  "/attachments/:id",
20
18
  Effect.gen(function* () {
21
- // The route states its own authorization. It reuses the rule of the action it mirrors.
19
+ // The rule of the action it mirrors.
22
20
  yield* authorize(GetAttachment);
23
21
 
24
22
  const member = yield* CurrentMember;
25
23
  const { id = "" } = yield* HttpRouter.params;
26
24
  const { image } = yield* store.get(member.workspace, id);
27
25
 
28
- // Authentication marks what it covers `no-store` unless the route states its own
29
- // caching. An attachment never changes, but it is one workspace's: `private`.
26
+ // An attachment never changes, but it is one workspace's: `private`.
30
27
  return HttpServerResponse.uint8Array(image.data, {
31
28
  contentType: image.mimeType,
32
29
  headers: {
@@ -16,11 +16,10 @@ export const attachments = Action.implement(
16
16
  return {
17
17
  attachImage: ({ issue, name, image }) =>
18
18
  Effect.gen(function* () {
19
- // Input that decodes but cannot be served is the built-in `InvalidInput`, which
20
- // every surface declares: no second error for bad input. Its `issues` name the
19
+ // Input that decodes but cannot be served is `InvalidInput`; its `issues` name the
21
20
  // field, as the library's own do for input that does not decode.
22
21
  if (!image.mimeType.startsWith("image/")) {
23
- const message = `Expected an image, got ${image.mimeType}.`;
22
+ const message = "Expected an image MIME type.";
24
23
 
25
24
  return yield* new Action.InvalidInput({
26
25
  message,