@gjermundgaraba/effect-actions 0.18.2 → 0.19.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.
- package/README.md +20 -47
- package/dist/Action.d.mts +96 -47
- package/dist/Action.mjs +11 -14
- package/dist/ActionCli.d.mts +81 -42
- package/dist/ActionCli.mjs +52 -33
- package/dist/ActionHttp.d.mts +84 -43
- package/dist/ActionHttp.mjs +2 -2
- package/dist/ActionMcp.d.mts +54 -27
- package/dist/ActionMcp.mjs +5 -5
- package/dist/ActionRpc.d.mts +48 -35
- package/dist/ActionRpc.mjs +1 -1
- package/dist/ActionToolkit.d.mts +32 -13
- package/dist/ActionToolkit.mjs +10 -7
- package/dist/Authentication.d.mts +92 -40
- package/dist/Authentication.mjs +37 -35
- package/dist/Testing.d.mts +22 -24
- package/dist/authentication/provider.d.mts +1 -4
- package/dist/authentication/provider.mjs +1 -1
- package/dist/authentication/refusal.mjs +2 -1
- package/dist/cli/commands.d.mts +34 -11
- package/dist/cli/commands.mjs +47 -22
- package/dist/contract/caller.mjs +1 -1
- package/dist/contract/declared.mjs +27 -14
- package/dist/contract/errors.d.mts +42 -17
- package/dist/contract/errors.mjs +42 -17
- package/dist/contract/hints.mjs +3 -2
- package/dist/contract/implementation.d.mts +5 -8
- package/dist/contract/implementation.mjs +29 -17
- package/dist/contract/media.d.mts +6 -2
- package/dist/contract/media.mjs +6 -2
- package/dist/contract/names.mjs +14 -12
- package/dist/contract/status.mjs +3 -10
- package/dist/http/client.d.mts +15 -4
- package/dist/http/client.mjs +1 -1
- package/dist/mcp/client.d.mts +21 -8
- package/dist/mcp/client.mjs +6 -8
- package/docs/Action-failures.md +6 -15
- package/docs/Action.md +159 -191
- package/docs/ActionCli-failures.md +12 -92
- package/docs/ActionCli.md +102 -156
- package/docs/ActionHttp-failures.md +14 -76
- package/docs/ActionHttp.md +150 -176
- package/docs/ActionMcp-failures.md +2 -22
- package/docs/ActionMcp.md +79 -170
- package/docs/ActionRpc-failures.md +3 -12
- package/docs/ActionRpc.md +183 -120
- package/docs/ActionToolkit-failures.md +3 -10
- package/docs/ActionToolkit.md +112 -109
- package/docs/Authentication-advanced.md +167 -108
- package/docs/Authentication-failures.md +11 -91
- package/docs/Authentication.md +114 -135
- package/docs/README.md +45 -30
- package/docs/Testing-failures.md +2 -6
- package/docs/Testing.md +126 -159
- package/docs/setup-failures.md +8 -32
- package/docs/setup.md +42 -37
- package/examples/README.md +6 -10
- package/examples/assistant/README.md +20 -38
- package/examples/assistant/src/agent.ts +2 -3
- package/examples/assistant/src/cli.ts +4 -9
- package/examples/assistant/src/commands.ts +6 -4
- package/examples/assistant/src/identity/authorize.ts +3 -3
- package/examples/assistant/src/tasks/Tasks.ts +1 -2
- package/examples/assistant/src/tasks/handlers.ts +3 -3
- package/examples/assistant/src/toolkit.ts +6 -8
- package/examples/assistant/test/commands.test.ts +16 -11
- package/examples/assistant/test/tasks/tasks.test.ts +0 -2
- package/examples/assistant/test/toolkit.test.ts +0 -1
- package/examples/kitchen-sink/README.md +30 -52
- package/examples/kitchen-sink/src/Actions.ts +2 -3
- package/examples/kitchen-sink/src/Api.ts +3 -4
- package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
- package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
- package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
- package/examples/kitchen-sink/src/agent.ts +1 -1
- package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
- package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
- package/examples/kitchen-sink/src/attachments/download.ts +5 -8
- package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
- package/examples/kitchen-sink/src/browser.ts +3 -4
- package/examples/kitchen-sink/src/cli.ts +6 -7
- package/examples/kitchen-sink/src/config.ts +2 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
- package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
- package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
- package/examples/kitchen-sink/src/identity/quota.ts +1 -1
- package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
- package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
- package/examples/kitchen-sink/src/issues/guide.ts +2 -3
- package/examples/kitchen-sink/src/ops.ts +3 -6
- package/examples/kitchen-sink/src/request-policy.ts +3 -3
- package/examples/kitchen-sink/src/routes.ts +4 -6
- package/examples/kitchen-sink/src/seed.ts +0 -1
- package/examples/kitchen-sink/src/server.ts +11 -12
- package/examples/kitchen-sink/src/stdio.ts +3 -2
- package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
- package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
- package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
- package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
- package/examples/kitchen-sink/test/request-policy.test.ts +1 -1
- package/examples/kitchen-sink/test/routes.test.ts +3 -3
- package/examples/live-rpc/README.md +8 -49
- package/examples/live-rpc/src/browser.ts +8 -41
- package/examples/live-rpc/src/chat/Chat.ts +9 -3
- package/examples/live-rpc/src/chat/room.ts +1 -1
- package/examples/live-rpc/src/client.ts +2 -3
- package/examples/live-rpc/src/page.ts +34 -0
- package/examples/live-rpc/src/routes.ts +6 -7
- package/examples/live-rpc/src/server.ts +6 -4
- package/examples/live-rpc/test/browser.test.ts +6 -5
- package/examples/live-rpc/test/routes.test.ts +16 -15
- package/examples/live-rpc/test/server.test.ts +1 -1
- package/examples/mcp-tools/README.md +22 -45
- package/examples/mcp-tools/src/printers/Printers.ts +3 -7
- package/examples/mcp-tools/src/routes.ts +0 -7
- package/examples/mcp-tools/src/server.ts +1 -1
- package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
- package/examples/mcp-tools/test/routes.test.ts +2 -3
- package/examples/notes-service/README.md +9 -43
- package/examples/notes-service/src/cli.ts +0 -3
- package/examples/notes-service/src/commands.ts +7 -17
- package/examples/notes-service/src/identity/authorize.ts +2 -5
- package/examples/notes-service/src/identity/verifier.ts +3 -4
- package/examples/notes-service/src/notes/Notes.ts +2 -6
- package/examples/notes-service/src/notes/store.ts +5 -1
- package/examples/notes-service/src/routes.ts +6 -8
- package/examples/notes-service/src/server.ts +4 -3
- package/examples/notes-service/src/stdio.ts +4 -3
- package/examples/notes-service/test/commands.test.ts +1 -1
- package/examples/notes-service/test/notes/as.ts +6 -0
- package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
- package/examples/notes-service/test/notes/in-process.test.ts +50 -0
- package/examples/notes-service/test/notes/notes.test.ts +3 -42
- package/examples/notes-service/test/routes.test.ts +2 -2
- package/examples/quickstart/README.md +1 -1
- package/examples/quickstart/src/client.ts +1 -0
- package/examples/quickstart/src/server.ts +2 -4
- package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
- package/package.json +1 -1
- package/docs/Action-in-process.md +0 -93
- package/docs/CONTEXT.md +0 -46
- 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.
|
|
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
|
|
79
|
-
|
|
|
80
|
-
| Implementations as a native Effect AI `Toolkit`
|
|
81
|
-
| Native tool titles in the approval display
|
|
82
|
-
| One approval rule reading the call and its caller
|
|
83
|
-
| A turn run as a member, and an approval answered through a `Chat`
|
|
84
|
-
| Where a real `LanguageModel` plugs in
|
|
85
|
-
| Local commands with `positional`, `aliases`, `render`, `stdin`
|
|
86
|
-
| Services provided on the command, and the host naming the caller
|
|
87
|
-
| A command's result alone on stdout, its handler's logs on stderr
|
|
88
|
-
| `logToStderr` moving a defect's report from stdout to stderr
|
|
89
|
-
| An identity only the host builds: the trusted operator
|
|
90
|
-
| An authorizer built once from a service, reading the caller per call
|
|
91
|
-
| One service with two Layers,
|
|
92
|
-
| Changes serialized, since a turn's tool calls run concurrently
|
|
93
|
-
| Testing handlers and authorization, each caller around its calls
|
|
94
|
-
| Testing a Layer over a temporary file, its path set through config
|
|
95
|
-
| Testing a tool call, an approval required, an approval answered
|
|
96
|
-
| Testing a command's syntax and output without a process
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
7
|
-
//
|
|
8
|
-
// returns
|
|
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.
|
|
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.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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
|
|
19
|
-
//
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
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
|
|
269
|
-
|
|
|
270
|
-
| `remote`: `name`, `commands`, `client`
|
|
271
|
-
| `commands`: `stdin`, `positional`, `aliases`, `render`, `name` | `src/cli.ts` |
|
|
272
|
-
| A client from `Config`, its credentials optional
|
|
273
|
-
| `make`, with a trusted identity the host supplies
|
|
274
|
-
| `logToStderr`
|
|
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
|
|
308
|
-
`
|
|
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
|
-
*
|
|
16
|
-
* itself, and its tools are already here.
|
|
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`:
|
|
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
|
|
19
|
-
//
|
|
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.
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
23
|
-
//
|
|
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
|
|
36
|
-
//
|
|
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)
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
22
|
+
const message = "Expected an image MIME type.";
|
|
24
23
|
|
|
25
24
|
return yield* new Action.InvalidInput({
|
|
26
25
|
message,
|