@gjermundgaraba/effect-actions 0.11.0 → 0.13.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 +102 -41
- package/dist/Action.d.mts +31 -117
- package/dist/Action.mjs +14 -53
- package/dist/ActionCli.d.mts +60 -28
- package/dist/ActionCli.mjs +54 -48
- package/dist/ActionHttp.d.mts +18 -67
- package/dist/ActionHttp.mjs +20 -70
- package/dist/ActionMcp.d.mts +23 -29
- package/dist/ActionMcp.mjs +56 -114
- package/dist/ActionRpc.d.mts +147 -0
- package/dist/ActionRpc.mjs +113 -0
- package/dist/ActionToolkit.d.mts +28 -9
- package/dist/ActionToolkit.mjs +31 -4
- package/dist/Authentication.d.mts +15 -15
- package/dist/Authentication.mjs +42 -96
- package/dist/Testing.d.mts +30 -23
- package/dist/Testing.mjs +27 -44
- package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
- package/dist/authentication/provider.mjs +12 -0
- package/dist/authentication/refusal.mjs +36 -0
- package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
- package/dist/cli/commands.mjs +302 -0
- package/dist/{internal → contract}/call.d.mts +2 -2
- package/dist/contract/call.mjs +10 -0
- package/dist/{internal → contract}/errors.d.mts +13 -10
- package/dist/{internal → contract}/errors.mjs +9 -14
- package/dist/{internal → contract}/implementation.d.mts +37 -21
- package/dist/{internal → contract}/implementation.mjs +18 -49
- package/dist/contract/media.d.mts +13 -0
- package/dist/contract/media.mjs +18 -0
- package/dist/contract/rules.d.mts +25 -0
- package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
- package/dist/{internal → http}/client.d.mts +7 -9
- package/dist/http/client.mjs +17 -0
- package/dist/http/declared.mjs +20 -0
- package/dist/http/schema-errors.mjs +9 -0
- package/dist/mcp/protocol.d.mts +18 -0
- package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
- package/dist/stdio/console.mjs +64 -0
- package/dist/{internal → testing}/memory.d.mts +1 -1
- package/dist/{internal → testing}/memory.mjs +5 -17
- package/dist/toolkit/tools.mjs +29 -0
- package/docs/Action-failures.md +47 -0
- package/docs/Action-in-process.md +93 -0
- package/docs/Action.md +211 -426
- package/docs/ActionCli-failures.md +104 -0
- package/docs/ActionCli.md +208 -293
- package/docs/ActionHttp-failures.md +83 -0
- package/docs/ActionHttp.md +205 -282
- package/docs/ActionMcp-failures.md +31 -0
- package/docs/ActionMcp.md +222 -271
- package/docs/ActionRpc-failures.md +21 -0
- package/docs/ActionRpc.md +179 -0
- package/docs/ActionToolkit-failures.md +15 -0
- package/docs/ActionToolkit.md +103 -119
- package/docs/Authentication-advanced.md +139 -0
- package/docs/Authentication-failures.md +99 -0
- package/docs/Authentication.md +143 -392
- package/docs/CONTEXT.md +40 -24
- package/docs/README.md +75 -35
- package/docs/Testing-failures.md +21 -0
- package/docs/Testing.md +193 -151
- package/docs/guarantees.md +35 -96
- package/docs/setup-failures.md +37 -0
- package/docs/setup.md +90 -92
- package/examples/README.md +68 -0
- package/examples/assistant/README.md +92 -0
- package/examples/assistant/src/agent.ts +92 -0
- package/examples/assistant/src/cli.ts +34 -0
- package/examples/assistant/src/commands.ts +25 -0
- package/examples/assistant/src/identity/Identity.ts +33 -0
- package/examples/assistant/src/identity/authorize.ts +24 -0
- package/examples/assistant/src/identity/handlers.ts +10 -0
- package/examples/assistant/src/identity/members.ts +9 -0
- package/examples/assistant/src/identity/permissions.ts +23 -0
- package/examples/assistant/src/scripted.ts +23 -0
- package/examples/assistant/src/tasks/Tasks.ts +63 -0
- package/examples/assistant/src/tasks/handlers.ts +25 -0
- package/examples/assistant/src/tasks/store.ts +96 -0
- package/examples/assistant/src/toolkit.ts +30 -0
- package/examples/assistant/test/commands.test.ts +57 -0
- package/examples/assistant/test/identity/identity.test.ts +43 -0
- package/examples/assistant/test/tasks/store.test.ts +48 -0
- package/examples/assistant/test/tasks/tasks.test.ts +75 -0
- package/examples/assistant/test/toolkit.test.ts +121 -0
- package/examples/kitchen-sink/README.md +318 -0
- package/examples/kitchen-sink/src/Actions.ts +19 -0
- package/examples/kitchen-sink/src/Api.ts +24 -0
- package/examples/kitchen-sink/src/Rpc.ts +12 -0
- package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
- package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
- package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
- package/examples/kitchen-sink/src/admin/grants.ts +22 -0
- package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
- package/examples/kitchen-sink/src/admin/keys.ts +8 -0
- package/examples/kitchen-sink/src/agent.ts +47 -0
- package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
- package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
- package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
- package/examples/kitchen-sink/src/assistant/handlers.ts +126 -0
- package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
- package/examples/kitchen-sink/src/attachments/download.ts +49 -0
- package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
- package/examples/kitchen-sink/src/attachments/store.ts +58 -0
- package/examples/kitchen-sink/src/browser.ts +33 -0
- package/examples/kitchen-sink/src/cli.ts +85 -0
- package/examples/kitchen-sink/src/config.ts +18 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
- package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
- package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
- package/examples/kitchen-sink/src/identity/directory.ts +52 -0
- package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
- package/examples/kitchen-sink/src/identity/members.ts +13 -0
- package/examples/kitchen-sink/src/identity/quota.ts +62 -0
- package/examples/kitchen-sink/src/issues/Issues.ts +111 -0
- package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
- package/examples/kitchen-sink/src/issues/guide.ts +37 -0
- package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
- package/examples/kitchen-sink/src/issues/store.ts +96 -0
- package/examples/kitchen-sink/src/issues/write-limit.ts +42 -0
- package/examples/kitchen-sink/src/memory.ts +30 -0
- package/examples/kitchen-sink/src/ops.ts +42 -0
- package/examples/kitchen-sink/src/request-policy.ts +63 -0
- package/examples/kitchen-sink/src/routes.ts +120 -0
- package/examples/kitchen-sink/src/sdk.ts +46 -0
- package/examples/kitchen-sink/src/seed.ts +65 -0
- package/examples/kitchen-sink/src/server.ts +62 -0
- package/examples/kitchen-sink/src/stdio.ts +34 -0
- package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
- package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
- package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
- package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
- package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
- package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
- package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
- package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
- package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
- package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
- package/examples/kitchen-sink/test/routes.test.ts +123 -0
- package/examples/kitchen-sink/test/support/scripted.ts +40 -0
- package/examples/live-rpc/README.md +107 -0
- package/examples/live-rpc/src/Rpc.ts +8 -0
- package/examples/live-rpc/src/browser.ts +98 -0
- package/examples/live-rpc/src/chat/Chat.ts +77 -0
- package/examples/live-rpc/src/chat/authorize.ts +16 -0
- package/examples/live-rpc/src/chat/handlers.ts +50 -0
- package/examples/live-rpc/src/chat/room.ts +23 -0
- package/examples/live-rpc/src/chat/store.ts +54 -0
- package/examples/live-rpc/src/client.ts +37 -0
- package/examples/live-rpc/src/identity/Identity.ts +19 -0
- package/examples/live-rpc/src/identity/members.ts +8 -0
- package/examples/live-rpc/src/identity/verifier.ts +23 -0
- package/examples/live-rpc/src/routes.ts +22 -0
- package/examples/live-rpc/src/server.ts +43 -0
- package/examples/live-rpc/test/browser.test.ts +105 -0
- package/examples/live-rpc/test/chat/chat.test.ts +69 -0
- package/examples/live-rpc/test/routes.test.ts +99 -0
- package/examples/live-rpc/test/server.test.ts +75 -0
- package/examples/mcp-tools/README.md +141 -0
- package/examples/mcp-tools/src/instructions.ts +12 -0
- package/examples/mcp-tools/src/printers/Printers.ts +121 -0
- package/examples/mcp-tools/src/printers/fake.ts +115 -0
- package/examples/mcp-tools/src/printers/farm.ts +136 -0
- package/examples/mcp-tools/src/printers/handlers.ts +29 -0
- package/examples/mcp-tools/src/routes.ts +52 -0
- package/examples/mcp-tools/src/server.ts +31 -0
- package/examples/mcp-tools/src/stdio.ts +26 -0
- package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
- package/examples/mcp-tools/test/routes.test.ts +202 -0
- package/examples/notes-service/README.md +111 -0
- package/examples/notes-service/src/Api.ts +13 -0
- package/examples/notes-service/src/browser.ts +41 -0
- package/examples/notes-service/src/cli.ts +22 -0
- package/examples/notes-service/src/commands.ts +137 -0
- package/examples/notes-service/src/identity/Identity.ts +31 -0
- package/examples/notes-service/src/identity/authorize.ts +18 -0
- package/examples/notes-service/src/identity/handlers.ts +10 -0
- package/examples/notes-service/src/identity/users.ts +9 -0
- package/examples/notes-service/src/identity/verifier.ts +36 -0
- package/examples/notes-service/src/notes/Notes.ts +119 -0
- package/examples/notes-service/src/notes/handlers.ts +39 -0
- package/examples/notes-service/src/notes/store.ts +84 -0
- package/examples/notes-service/src/routes.ts +45 -0
- package/examples/notes-service/src/server.ts +26 -0
- package/examples/notes-service/src/status/Status.ts +10 -0
- package/examples/notes-service/src/status/handlers.ts +14 -0
- package/examples/notes-service/src/stdio.ts +25 -0
- package/examples/notes-service/test/commands.test.ts +288 -0
- package/examples/notes-service/test/notes/notes.test.ts +103 -0
- package/examples/notes-service/test/routes.test.ts +84 -0
- package/examples/quickstart/README.md +31 -0
- package/examples/quickstart/src/Api.ts +5 -0
- package/examples/quickstart/src/client.ts +13 -0
- package/examples/quickstart/src/greeting/Greeting.ts +12 -0
- package/examples/quickstart/src/greeting/handlers.ts +6 -0
- package/examples/quickstart/src/routes.ts +11 -0
- package/examples/quickstart/src/server.ts +16 -0
- package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
- package/package.json +27 -9
- package/dist/internal/actions.d.mts +0 -9
- package/dist/internal/authentication.mjs +0 -11
- package/dist/internal/call.mjs +0 -12
- package/dist/internal/cli.mjs +0 -328
- package/dist/internal/client.mjs +0 -25
- package/dist/internal/console.mjs +0 -77
- package/dist/internal/declared.d.mts +0 -13
- package/dist/internal/declared.mjs +0 -34
- package/dist/internal/mcp.d.mts +0 -14
- package/dist/internal/media.d.mts +0 -8
- package/dist/internal/media.mjs +0 -26
- package/dist/internal/refusal.mjs +0 -67
- package/dist/internal/schema-errors.mjs +0 -20
- package/dist/internal/tools.mjs +0 -44
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import { Effect, Ref } from "effect";
|
|
3
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
4
|
+
import { identity } from "../../src/identity/handlers.js";
|
|
5
|
+
import { CurrentCaller, type Member, type Operator } from "../../src/identity/Identity.js";
|
|
6
|
+
import { members } from "../../src/identity/members.js";
|
|
7
|
+
import { type Permission, Permissions } from "../../src/identity/permissions.js";
|
|
8
|
+
|
|
9
|
+
const as = (caller: Member | Operator) => Effect.provideService(CurrentCaller, caller);
|
|
10
|
+
|
|
11
|
+
const operator: Operator = { kind: "operator", id: "root" };
|
|
12
|
+
|
|
13
|
+
describe("identity, in process", () => {
|
|
14
|
+
it.effect("answers each call with its own caller, from one client", () =>
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const client = yield* Action.client(identity);
|
|
17
|
+
|
|
18
|
+
expect(yield* client.whoAmI().pipe(as(members.ada))).toEqual({ id: "ada", kind: "member" });
|
|
19
|
+
expect(yield* client.whoAmI().pipe(as(operator))).toEqual({ id: "root", kind: "operator" });
|
|
20
|
+
}).pipe(Effect.provide(Permissions.layerMemory)),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
it.effect("gets the permission store once and asks it on every call", () =>
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
const granted = yield* Ref.make<ReadonlyArray<Permission>>([]);
|
|
26
|
+
|
|
27
|
+
// The store is a startup service, so it is provided where the client is acquired: the
|
|
28
|
+
// authorizer is built there. No call is given it again.
|
|
29
|
+
const client = yield* Action.client(identity).pipe(
|
|
30
|
+
Effect.provideService(Permissions, Permissions.of({ granted: () => Ref.get(granted) })),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const refusal = yield* Effect.flip(client.whoAmI().pipe(as(members.ada)));
|
|
34
|
+
|
|
35
|
+
expect(refusal).toBeInstanceOf(Action.Forbidden);
|
|
36
|
+
expect(refusal.message).toBe("Requires tasks:read.");
|
|
37
|
+
|
|
38
|
+
yield* Ref.set(granted, ["tasks:read"]);
|
|
39
|
+
|
|
40
|
+
expect(yield* client.whoAmI().pipe(as(members.ada))).toEqual({ id: "ada", kind: "member" });
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { NodeServices } from "@effect/platform-node";
|
|
2
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
3
|
+
import { ConfigProvider, Effect, FileSystem, Layer, Path } from "effect";
|
|
4
|
+
import { TaskStore } from "../../src/tasks/store.js";
|
|
5
|
+
|
|
6
|
+
// The file store, on a file of the test's own: `ASSISTANT_TASKS` is set through a
|
|
7
|
+
// `ConfigProvider`, as the environment sets it for an entrypoint. The directory is removed
|
|
8
|
+
// with the test's scope.
|
|
9
|
+
const fileStore = Effect.gen(function* () {
|
|
10
|
+
const files = yield* FileSystem.FileSystem;
|
|
11
|
+
const path = yield* Path.Path;
|
|
12
|
+
const directory = yield* files.makeTempDirectoryScoped();
|
|
13
|
+
const tasks = path.join(directory, "tasks.json");
|
|
14
|
+
const config = ConfigProvider.fromUnknown({ ASSISTANT_TASKS: tasks });
|
|
15
|
+
|
|
16
|
+
return TaskStore.layerFile.pipe(Layer.provide(ConfigProvider.layer(config)));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("the file store", () => {
|
|
20
|
+
it.effect("gives each of several concurrent adds, as one model turn makes them, its own id", () =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const store = yield* TaskStore;
|
|
23
|
+
|
|
24
|
+
const added = yield* Effect.all(
|
|
25
|
+
["Book the venue", "Send invitations", "Order lunch"].map((title) =>
|
|
26
|
+
store.add("ada", title, "ada"),
|
|
27
|
+
),
|
|
28
|
+
{ concurrency: "unbounded" },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(added.map(({ id }) => id)).toEqual(["1", "2", "3"]);
|
|
32
|
+
expect(yield* store.list("ada")).toEqual(added);
|
|
33
|
+
}).pipe(Effect.provide(Layer.unwrap(fileStore)), Effect.provide(NodeServices.layer)),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
it.effect("keeps tasks for the next process: a layer built again reads them", () =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const store = yield* fileStore;
|
|
39
|
+
const add = Effect.flatMap(TaskStore, (tasks) => tasks.add("root", "Rotate keys", "ada"));
|
|
40
|
+
const list = Effect.flatMap(TaskStore, (tasks) => tasks.list(undefined));
|
|
41
|
+
|
|
42
|
+
// `local` builds the layer anew for each, as each run of the agent or of a command does.
|
|
43
|
+
const task = yield* add.pipe(Effect.provide(store, { local: true }));
|
|
44
|
+
|
|
45
|
+
expect(yield* list.pipe(Effect.provide(store, { local: true }))).toEqual([task]);
|
|
46
|
+
}).pipe(Effect.provide(NodeServices.layer)),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
4
|
+
import { CurrentCaller, type Member, type Operator } from "../../src/identity/Identity.js";
|
|
5
|
+
import { members } from "../../src/identity/members.js";
|
|
6
|
+
import { Permissions } from "../../src/identity/permissions.js";
|
|
7
|
+
import { tasks } from "../../src/tasks/handlers.js";
|
|
8
|
+
import { TaskNotFound } from "../../src/tasks/Tasks.js";
|
|
9
|
+
import { TaskStore } from "../../src/tasks/store.js";
|
|
10
|
+
|
|
11
|
+
// What the implementation does, with no model and no command line: `Action.client` runs the
|
|
12
|
+
// same decoding, `authorize` and handler every surface runs. Each call names its caller.
|
|
13
|
+
const as = (caller: Member | Operator) => Effect.provideService(CurrentCaller, caller);
|
|
14
|
+
|
|
15
|
+
const operator: Operator = { kind: "operator", id: "root" };
|
|
16
|
+
|
|
17
|
+
// A test supplies its services as an entrypoint does: here the store is memory.
|
|
18
|
+
const services = Layer.mergeAll(TaskStore.layerMemory, Permissions.layerMemory);
|
|
19
|
+
|
|
20
|
+
describe("tasks, in process", () => {
|
|
21
|
+
it.effect("records which caller added a task, a member or the operator", () =>
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const client = yield* Action.client(tasks);
|
|
24
|
+
|
|
25
|
+
yield* client.addTask({ title: "Book the venue", assignee: "ada" }).pipe(as(members.ada));
|
|
26
|
+
yield* client.addTask({ title: "Send invitations", assignee: "grace" }).pipe(as(operator));
|
|
27
|
+
|
|
28
|
+
const { tasks: listed } = yield* client.listTasks().pipe(as(members.grace));
|
|
29
|
+
|
|
30
|
+
expect(listed.map(({ id, addedBy }) => [id, addedBy])).toEqual([
|
|
31
|
+
["1", "ada"],
|
|
32
|
+
["2", "root"],
|
|
33
|
+
]);
|
|
34
|
+
expect(yield* client.listTasks({ assignee: "grace" }).pipe(as(members.grace))).toMatchObject({
|
|
35
|
+
tasks: [{ title: "Send invitations" }],
|
|
36
|
+
});
|
|
37
|
+
}).pipe(Effect.provide(services)),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
it.effect("refuses a write from a member the store grants reading alone", () =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const client = yield* Action.client(tasks);
|
|
43
|
+
const add = client.addTask({ title: "Order lunch", assignee: "guest" });
|
|
44
|
+
|
|
45
|
+
const refusal = yield* Effect.flip(add.pipe(as(members.guest)));
|
|
46
|
+
|
|
47
|
+
// `toEqual` on an error compares its fields and never its message, so the message is
|
|
48
|
+
// asserted on its own.
|
|
49
|
+
expect(refusal).toBeInstanceOf(Action.Forbidden);
|
|
50
|
+
expect(refusal.message).toBe("Requires tasks:write.");
|
|
51
|
+
expect(yield* client.listTasks().pipe(as(members.guest))).toEqual({ tasks: [] });
|
|
52
|
+
}).pipe(Effect.provide(services)),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
it.effect("admits the operator, whom the permission store does not know", () =>
|
|
56
|
+
Effect.gen(function* () {
|
|
57
|
+
const client = yield* Action.client(tasks);
|
|
58
|
+
const add = client.addTask({ title: "Rotate the keys", assignee: "ada" });
|
|
59
|
+
const { id } = yield* add.pipe(as(operator));
|
|
60
|
+
|
|
61
|
+
expect(yield* client.completeTask({ id }).pipe(as(operator))).toMatchObject({ done: true });
|
|
62
|
+
}).pipe(Effect.provide(services)),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
it.effect("fails with the declared error for a task that does not exist", () =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const client = yield* Action.client(tasks);
|
|
68
|
+
const annotate = client.annotateTask({ id: "9", notes: "Urgent." });
|
|
69
|
+
|
|
70
|
+
expect(yield* Effect.flip(annotate.pipe(as(members.ada)))).toEqual(
|
|
71
|
+
new TaskNotFound({ id: "9" }),
|
|
72
|
+
);
|
|
73
|
+
}).pipe(Effect.provide(services)),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import { Effect, Layer, Stream } from "effect";
|
|
3
|
+
import { Chat, LanguageModel } from "effect/ai";
|
|
4
|
+
import type { Member } from "../src/identity/Identity.js";
|
|
5
|
+
import { members } from "../src/identity/members.js";
|
|
6
|
+
import { Permissions } from "../src/identity/permissions.js";
|
|
7
|
+
import { scripted } from "../src/scripted.js";
|
|
8
|
+
import { TaskStore } from "../src/tasks/store.js";
|
|
9
|
+
import { actingFor, layer, toolkit } from "../src/toolkit.js";
|
|
10
|
+
|
|
11
|
+
// What the toolkit adds to the implementations: a caller per call, and a model's approvals.
|
|
12
|
+
// The handlers run in this process, on a store in memory the tests can read too.
|
|
13
|
+
const tools = layer.pipe(
|
|
14
|
+
Layer.provideMerge(Layer.mergeAll(TaskStore.layerMemory, Permissions.layerMemory)),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// A model's call of `addTask`, as a provider sends it.
|
|
18
|
+
const adding = (id: string, assignee: string) =>
|
|
19
|
+
({ type: "tool-call", id, name: "addTask", params: { title: "Order lunch", assignee } }) as const;
|
|
20
|
+
|
|
21
|
+
// Which calls of a turn ran, and which wait for a person.
|
|
22
|
+
const outcome = (response: LanguageModel.GenerateTextResponse<typeof toolkit.tools, "opaque">) => ({
|
|
23
|
+
ran: response.toolResults.map(({ id }) => id),
|
|
24
|
+
waiting: response.content.flatMap((part) =>
|
|
25
|
+
part.type === "tool-approval-request" ? [part.toolCallId] : [],
|
|
26
|
+
),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("the toolkit", () => {
|
|
30
|
+
it.effect("runs each call as the member provided around it", () =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const { handle } = yield* toolkit;
|
|
33
|
+
|
|
34
|
+
// A call by hand, with the JSON arguments a model sends. The member goes around the whole
|
|
35
|
+
// call, the collecting of its results included.
|
|
36
|
+
const add = handle("addTask", { title: "Order lunch", assignee: "guest" }).pipe(
|
|
37
|
+
Effect.flatMap(Stream.runCollect),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// A refusal is not raised: it is a result the model reads, as a declared error is.
|
|
41
|
+
expect(yield* add.pipe(actingFor(members.guest))).toMatchObject([
|
|
42
|
+
{ isFailure: true, result: { message: "Requires tasks:write." } },
|
|
43
|
+
]);
|
|
44
|
+
expect(yield* add.pipe(actingFor(members.ada))).toMatchObject([
|
|
45
|
+
{ isFailure: false, result: { id: "1", addedBy: "ada" } },
|
|
46
|
+
]);
|
|
47
|
+
}).pipe(Effect.provide(tools)),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
it.effect("asks for approval before a model adds to a list that is not its caller's", () =>
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
// `LanguageModel` is what enforces approval: a call by hand, as above, is never asked.
|
|
53
|
+
// The model answers each member with the same two calls.
|
|
54
|
+
const plan = (member: Member) =>
|
|
55
|
+
LanguageModel.generateText({ prompt: "Plan the launch.", toolkit }).pipe(
|
|
56
|
+
actingFor(member),
|
|
57
|
+
Effect.map(outcome),
|
|
58
|
+
Effect.provide(scripted([adding("for ada", "ada"), adding("for grace", "grace")])),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(yield* plan(members.ada)).toEqual({ ran: ["for ada"], waiting: ["for grace"] });
|
|
62
|
+
expect(yield* plan(members.grace)).toEqual({ ran: ["for grace"], waiting: ["for ada"] });
|
|
63
|
+
|
|
64
|
+
// A call that waits has not run.
|
|
65
|
+
const store = yield* TaskStore;
|
|
66
|
+
|
|
67
|
+
expect((yield* store.list(undefined)).map(({ addedBy }) => addedBy)).toEqual([
|
|
68
|
+
"ada",
|
|
69
|
+
"grace",
|
|
70
|
+
]);
|
|
71
|
+
}).pipe(Effect.provide(tools)),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
it.effect("authorizes an approved call too: approval lets no one do what they may not", () =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
// The `Chat` keeps the request in its history, which the answer is matched against.
|
|
77
|
+
const chat = yield* Chat.empty;
|
|
78
|
+
|
|
79
|
+
const asked = yield* chat
|
|
80
|
+
.generateText({ prompt: "Add lunch to Ada's list.", toolkit })
|
|
81
|
+
.pipe(actingFor(members.guest));
|
|
82
|
+
|
|
83
|
+
expect(outcome(asked)).toEqual({ ran: [], waiting: ["for ada"] });
|
|
84
|
+
|
|
85
|
+
// The approved call runs in the turn that carries the answer, as that turn's caller.
|
|
86
|
+
const answered = yield* chat
|
|
87
|
+
.generateText({
|
|
88
|
+
prompt: [
|
|
89
|
+
{
|
|
90
|
+
role: "tool",
|
|
91
|
+
content: asked.content.flatMap((part) =>
|
|
92
|
+
part.type === "tool-approval-request"
|
|
93
|
+
? [
|
|
94
|
+
{
|
|
95
|
+
type: "tool-approval-response",
|
|
96
|
+
approvalId: part.approvalId,
|
|
97
|
+
approved: true,
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
: [],
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
toolkit,
|
|
105
|
+
})
|
|
106
|
+
.pipe(actingFor(members.guest));
|
|
107
|
+
|
|
108
|
+
expect(answered.toolResults).toMatchObject([
|
|
109
|
+
{
|
|
110
|
+
id: "for ada",
|
|
111
|
+
isFailure: true,
|
|
112
|
+
result: { message: "Requires tasks:write." },
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const store = yield* TaskStore;
|
|
117
|
+
|
|
118
|
+
expect(yield* store.list(undefined)).toEqual([]);
|
|
119
|
+
}).pipe(Effect.provide(scripted([adding("for ada", "ada")])), Effect.provide(tools)),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# kitchen-sink
|
|
2
|
+
|
|
3
|
+
The reference application: one multi-tenant issue tracker that uses every module of the library
|
|
4
|
+
and nearly every option. Members of a workspace file, discuss and close issues, and attach
|
|
5
|
+
screenshots, over HTTP, MCP, Effect RPC, a CLI and an assistant, in process and behind a route.
|
|
6
|
+
Operators suspend workspaces on a second port, or from a console on the host.
|
|
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.
|
|
12
|
+
|
|
13
|
+
## Run it
|
|
14
|
+
|
|
15
|
+
From the repository root, after `vp install` (from an installed package, see
|
|
16
|
+
[Run one](../README.md#run-one)):
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
node --import tsx examples/kitchen-sink/src/server.ts # members on 127.0.0.1:3000, operators on :3001
|
|
20
|
+
node --import tsx examples/kitchen-sink/src/seed.ts # files ACME-1 with a screenshot and a comment
|
|
21
|
+
|
|
22
|
+
export TRACKER_TOKEN=ada TRACKER_WORKSPACE=acme # a demo member, see below
|
|
23
|
+
node --import tsx examples/kitchen-sink/src/cli.ts --help
|
|
24
|
+
node --import tsx examples/kitchen-sink/src/cli.ts list-issues -n 5
|
|
25
|
+
node --import tsx examples/kitchen-sink/src/cli.ts get-issue ACME-1
|
|
26
|
+
echo "It crashes on an empty draft" | node --import tsx examples/kitchen-sink/src/cli.ts create-issue --title Crash
|
|
27
|
+
node --import tsx examples/kitchen-sink/src/cli.ts ask-assistant --prompt "Close what is done." # answers with a call to approve
|
|
28
|
+
|
|
29
|
+
# The admin port takes an operator key, not a member's token.
|
|
30
|
+
curl -s 127.0.0.1:3001/admin/suspendWorkspace -H 'Content-Type: application/json' \
|
|
31
|
+
-H 'X-Operator-Key: ops-key' -d '{"workspace":"acme"}'
|
|
32
|
+
|
|
33
|
+
node --import tsx examples/kitchen-sink/src/ops.ts list-workspaces # the operators' console, no server
|
|
34
|
+
node --import tsx examples/kitchen-sink/src/agent.ts # the assistant, with a scripted model
|
|
35
|
+
|
|
36
|
+
vp test examples/kitchen-sink
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`HOST`, `PORT`, `ADMIN_PORT`, `PUBLIC_URL` and `MCP_ORIGIN` configure the server; `TRACKER_URL`
|
|
40
|
+
points the CLI and the seed at it. `stdio.ts` is an MCP server for a local agent to start as a
|
|
41
|
+
subprocess. `sdk.ts` and `browser.ts` are imported by client code, not run. The OpenAPI
|
|
42
|
+
document is at `/api/v1/openapi.json`, Swagger UI at `/docs`, the MCP endpoint at `/mcp`, the
|
|
43
|
+
socket at `/rpc`.
|
|
44
|
+
|
|
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 |
|
|
56
|
+
|
|
57
|
+
A bearer request names its workspace in `X-Workspace: acme`. `kay` (token `kay`, session
|
|
58
|
+
`kay-at-acme`) is a member of acme granted no scope yet. The session cookie also opens
|
|
59
|
+
`GET /attachments/<id>`, the image for a page of the web app; a token holder calls `getAttachment`.
|
|
60
|
+
|
|
61
|
+
## Layout
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
src/
|
|
65
|
+
├── identity/ who is calling
|
|
66
|
+
│ ├── Identity.ts contract: CurrentMember, scopes, the Login and Session descriptors, whoAmI
|
|
67
|
+
│ ├── members.ts DEMO ONLY: the people of the demo
|
|
68
|
+
│ ├── directory.ts Directory: who a token or a session names
|
|
69
|
+
│ ├── authenticate.ts Authentication.layer for both descriptors, OAuth discovery
|
|
70
|
+
│ ├── authorize.ts the members' rule: readOnly to a scope
|
|
71
|
+
│ ├── quota.ts the request quota, as HTTP and as RPC middleware
|
|
72
|
+
│ └── handlers.ts
|
|
73
|
+
├── workspaces/ the tenants
|
|
74
|
+
│ ├── Workspaces.ts contract: Workspace, the public getStatus
|
|
75
|
+
│ ├── registry.ts WorkspaceRegistry: the workspaces and their suspension
|
|
76
|
+
│ ├── tenant.ts Tenant, and the router middleware that resolves it per request
|
|
77
|
+
│ └── handlers.ts
|
|
78
|
+
├── issues/
|
|
79
|
+
│ ├── Issues.ts contract: schemas, errors, five actions
|
|
80
|
+
│ ├── store.ts IssueStore, scoped by workspace
|
|
81
|
+
│ ├── write-limit.ts the write limit, wrapped around handlers
|
|
82
|
+
│ ├── handlers.ts Action.implement over the store
|
|
83
|
+
│ ├── guide.ts a native MCP resource and prompt
|
|
84
|
+
│ └── assistant.ts the actions as a model's Toolkit, with approval
|
|
85
|
+
├── assistant/ the model loop, served: a turn per request
|
|
86
|
+
│ ├── Assistant.ts contract: a turn, askAssistant and answerAssistant
|
|
87
|
+
│ ├── conversations.ts Conversations: each member's Chat, one turn at a time, ended where a turn fails
|
|
88
|
+
│ ├── demo-model.ts DEMO ONLY: a scripted model
|
|
89
|
+
│ └── handlers.ts the loop, approvals across requests, what a turn reports
|
|
90
|
+
├── attachments/
|
|
91
|
+
│ ├── Attachments.ts contract: an image in an input and in a success
|
|
92
|
+
│ ├── store.ts
|
|
93
|
+
│ ├── handlers.ts
|
|
94
|
+
│ └── download.ts GET /attachments/:id, the host's own route, for the web app
|
|
95
|
+
├── admin/ the operators, a second audience
|
|
96
|
+
│ ├── Admin.ts contract: Operator, the OperatorKey descriptor, three actions
|
|
97
|
+
│ ├── grants.ts Grants: what each operator may do
|
|
98
|
+
│ ├── authorize.ts the operators' rule, built from Grants
|
|
99
|
+
│ ├── keys.ts DEMO ONLY: the operator keys
|
|
100
|
+
│ ├── authenticate.ts Authentication.layer for OperatorKey
|
|
101
|
+
│ └── handlers.ts
|
|
102
|
+
├── Actions.ts which actions each surface gets
|
|
103
|
+
├── Api.ts the two HTTP bindings
|
|
104
|
+
├── Rpc.ts the RPC binding
|
|
105
|
+
├── config.ts what a deployment sets
|
|
106
|
+
├── request-policy.ts the Host and Origin policy, and CORS
|
|
107
|
+
├── routes.ts every surface of both ports, and the builders they share
|
|
108
|
+
├── memory.ts DEMO ONLY: the services in memory
|
|
109
|
+
├── server.ts entrypoint: both ports, the rpcs over a WebSocket
|
|
110
|
+
├── stdio.ts entrypoint: MCP for a local agent
|
|
111
|
+
├── cli.ts entrypoint: the members' commands, calling the server
|
|
112
|
+
├── ops.ts entrypoint: the operators' console, in process
|
|
113
|
+
├── agent.ts entrypoint: the assistant
|
|
114
|
+
├── seed.ts entrypoint: an Effect program calling the API
|
|
115
|
+
├── sdk.ts entrypoint: a promise client
|
|
116
|
+
└── browser.ts entrypoint: the web app's RPC client
|
|
117
|
+
test/
|
|
118
|
+
├── routes.test.ts the host's surfaces: one port over HTTP and MCP, then both ports
|
|
119
|
+
├── request-policy.test.ts raw requests to each port as a web handler
|
|
120
|
+
├── issues/ in process, with Action.client; the assistant's tools
|
|
121
|
+
├── assistant/ the assistant over HTTP, with a scripted model
|
|
122
|
+
├── support/ the scripted model the assistant's tests give the routes
|
|
123
|
+
├── identity/ what authentication adds: tokens over HTTP, sessions over RPC
|
|
124
|
+
├── attachments/ images over HTTP and MCP; the download route
|
|
125
|
+
└── admin/ the operators' grants; the trusted operator
|
|
126
|
+
```
|
|
127
|
+
|
|
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
|
+
## Where to look
|
|
145
|
+
|
|
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` |
|
|
156
|
+
|
|
157
|
+
## Index
|
|
158
|
+
|
|
159
|
+
Each export and option the example uses, and where. A symbol in parentheses is the name to
|
|
160
|
+
search for in the file.
|
|
161
|
+
|
|
162
|
+
### Action
|
|
163
|
+
|
|
164
|
+
| Export or option | File |
|
|
165
|
+
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
166
|
+
| `make`: `input`, `success`, `error`, `readOnly` | `src/issues/Issues.ts` |
|
|
167
|
+
| `make`: `caller: Action.Anyone`, a public action | `src/workspaces/Workspaces.ts` (`GetStatus`) |
|
|
168
|
+
| `make`: `caller`, an identity service | `src/identity/Identity.ts` (`CurrentMember`, `WhoAmI`) |
|
|
169
|
+
| `make`: `mcp` (`title`, hints) | `src/issues/Issues.ts` (`CreateIssue`, `CloseIssue`) |
|
|
170
|
+
| `make`: no `input` | `src/identity/Identity.ts` (`WhoAmI`) |
|
|
171
|
+
| `make`: an optional and a defaulted input field | `src/issues/Issues.ts` (`ListIssues`) |
|
|
172
|
+
| `make`: errors with `httpApiStatus` | `src/issues/Issues.ts` (`IssueNotFound`, `RateLimited`) |
|
|
173
|
+
| `make`: a shared error list | `src/issues/Issues.ts` (`IssueWriteErrors`), `src/attachments/Attachments.ts` |
|
|
174
|
+
| `make`: an option bundle, spread | `src/issues/Issues.ts` (`change`), `src/admin/Admin.ts` (`onWorkspace`) |
|
|
175
|
+
| `Image`, in a success and in an input | `src/attachments/Attachments.ts` |
|
|
176
|
+
| `byName` | `src/issues/guide.ts` (`tools`) |
|
|
177
|
+
| `implement`: one action, a plain handler | `src/identity/handlers.ts` |
|
|
178
|
+
| `implement`: one action, a builder | `src/workspaces/handlers.ts` |
|
|
179
|
+
| `implement`: several actions sharing a builder | `src/issues/handlers.ts` |
|
|
180
|
+
| Startup services and per-request services | `src/issues/handlers.ts` |
|
|
181
|
+
| A rate limit in handlers, shared by several | `src/issues/write-limit.ts`, `src/issues/handlers.ts`, `src/attachments/handlers.ts` |
|
|
182
|
+
| `implement`: `authorize`, a rule from `readOnly` | `src/identity/authorize.ts` |
|
|
183
|
+
| `implement`: `authorize`, an Effect building it | `src/admin/authorize.ts` |
|
|
184
|
+
| `allowAll` | `src/identity/handlers.ts` |
|
|
185
|
+
| `Forbidden` with `scopes`, and without | `src/identity/authorize.ts`, `src/admin/authorize.ts` |
|
|
186
|
+
| `Unauthenticated` | `src/identity/directory.ts`, `src/admin/authenticate.ts` |
|
|
187
|
+
| `InvalidInput` with `issues`, from a handler | `src/attachments/handlers.ts` |
|
|
188
|
+
| `layer`, one builder run for an implementation two ports serve | `src/routes.ts` (`builders`), `src/server.ts`, `test/routes.test.ts` |
|
|
189
|
+
| `client` | `src/agent.ts`, `test/issues/issues.test.ts` |
|
|
190
|
+
| `client`: `actions` | `test/issues/issues.test.ts` |
|
|
191
|
+
| `Any`, an authorizer's parameter | `src/identity/authorize.ts` |
|
|
192
|
+
|
|
193
|
+
### Authentication
|
|
194
|
+
|
|
195
|
+
| Export or option | File |
|
|
196
|
+
| -------------------------------------------------------------- | --------------------------------------------------------------- |
|
|
197
|
+
| `make`, Bearer, with a declared `error` (503) | `src/identity/Identity.ts` (`Login`, `IssuerUnavailable`) |
|
|
198
|
+
| `make`: `security`, a cookie, for the same identity | `src/identity/Identity.ts` (`Session`) |
|
|
199
|
+
| `make`: `security`, an API key in a header | `src/admin/Admin.ts` (`OperatorKey`) |
|
|
200
|
+
| `layer`, a verifier built from startup services | `src/identity/authenticate.ts` |
|
|
201
|
+
| `layer`, a plain verifier | `src/admin/authenticate.ts` |
|
|
202
|
+
| `layer`: `protectedResource`, an Effect over `Config` | `src/identity/authenticate.ts` (`protectedResource`) |
|
|
203
|
+
| `ProtectedResource` | `src/identity/authenticate.ts` |
|
|
204
|
+
| A request service the verifier reads, and its order | `src/workspaces/tenant.ts`, `src/routes.ts` (`publicRoutes`) |
|
|
205
|
+
| `protect`, a route of the host's own, by the cookie descriptor | `src/attachments/download.ts` |
|
|
206
|
+
| A trusted identity no verifier returns | `src/admin/Admin.ts`, `src/admin/authenticate.ts`, `src/ops.ts` |
|
|
207
|
+
| A verifier's declared error over MCP | `test/identity/identity.test.ts` |
|
|
208
|
+
| A test replacing a verifier's service | `test/identity/identity.test.ts` (`issuerDown`) |
|
|
209
|
+
|
|
210
|
+
### ActionHttp
|
|
211
|
+
|
|
212
|
+
| Export or option | File |
|
|
213
|
+
| -------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
214
|
+
| `make`: `prefix`, `authentication`, `error` | `src/Api.ts` (`Http`) |
|
|
215
|
+
| A second binding for a second identity | `src/Api.ts` (`AdminHttp`) |
|
|
216
|
+
| One action in two bindings | `src/Api.ts` (`GetStatus`), `src/routes.ts` (`adminRoutes`) |
|
|
217
|
+
| `layer`: `actions` | `src/routes.ts` (`api`) |
|
|
218
|
+
| `layer`: `middleware` | `src/routes.ts` (`api`), `src/identity/quota.ts` (`HttpQuota`) |
|
|
219
|
+
| Global router middleware, before authentication and discovery | `src/request-policy.ts` (`requestPolicy`), `src/routes.ts` (`publicRoutes`) |
|
|
220
|
+
| `Http.api`, `Http.prefix`: the OpenAPI document and Swagger UI | `src/routes.ts` (`documentation`) |
|
|
221
|
+
| A request body limit | `src/server.ts` |
|
|
222
|
+
| Two `HttpRouter.serve`, what they share provided outside both | `src/server.ts` |
|
|
223
|
+
| `client`: `baseUrl`, `transformClient` | `src/seed.ts` |
|
|
224
|
+
| `fetchClient` | `src/sdk.ts` |
|
|
225
|
+
| Declared errors handled by tag | `src/seed.ts`, `src/sdk.ts` |
|
|
226
|
+
|
|
227
|
+
### ActionMcp
|
|
228
|
+
|
|
229
|
+
| Export or option | File |
|
|
230
|
+
| ------------------------------------------------ | --------------------------------------------------- |
|
|
231
|
+
| `layerHttp`, public and protected tools together | `src/routes.ts` (`mcp`) |
|
|
232
|
+
| `layerHttp`: `authentication`, `instructions` | `src/routes.ts` (`mcp`) |
|
|
233
|
+
| `layerHttp`: `actions`, one action left off | `src/routes.ts` (`mcp`), `src/Actions.ts` (`Tools`) |
|
|
234
|
+
| `layerHttp`: `allowedOrigins` | `src/routes.ts` (`mcp`), `test/routes.test.ts` |
|
|
235
|
+
| `layerHttp`: `features`, a resource and a prompt | `src/issues/guide.ts` |
|
|
236
|
+
| CORS for a browser MCP client | `src/request-policy.ts` (`cors`) |
|
|
237
|
+
| `runStdio`, the host naming the caller | `src/stdio.ts` |
|
|
238
|
+
| An image block from a media field | `src/attachments/Attachments.ts` (`GetAttachment`) |
|
|
239
|
+
|
|
240
|
+
### ActionRpc
|
|
241
|
+
|
|
242
|
+
| Export or option | File |
|
|
243
|
+
| --------------------------------- | ------------------------------------------------------------- |
|
|
244
|
+
| `make`: `authentication`, `error` | `src/Rpc.ts` |
|
|
245
|
+
| `layer`: `middleware` | `src/routes.ts` (`rpc`), `src/identity/quota.ts` (`RpcQuota`) |
|
|
246
|
+
| A WebSocket protocol | `src/server.ts` (`socket`) |
|
|
247
|
+
| `client`, over a WebSocket | `src/browser.ts` |
|
|
248
|
+
| A credential per message | `test/identity/session.test.ts` (`as`) |
|
|
249
|
+
|
|
250
|
+
### ActionToolkit
|
|
251
|
+
|
|
252
|
+
| Export or option | File |
|
|
253
|
+
| --------------------------------------------------- | ------------------------------------ |
|
|
254
|
+
| `make`: `actions`, `needsApproval` (a boolean rule) | `src/issues/assistant.ts` |
|
|
255
|
+
| `toolkit` given to `LanguageModel` | `src/issues/assistant.ts` (`triage`) |
|
|
256
|
+
| `layer`, and the caller per turn | `src/agent.ts` |
|
|
257
|
+
| `toolkit` and the model yielded in a builder | `src/assistant/handlers.ts` |
|
|
258
|
+
| `layer` provided to the routes | `src/routes.ts` (`assistantTools`) |
|
|
259
|
+
| An approval answered in a later request | `src/assistant/handlers.ts` |
|
|
260
|
+
| `tools.handle`, a call made by hand | `test/issues/assistant.test.ts` |
|
|
261
|
+
|
|
262
|
+
### ActionCli
|
|
263
|
+
|
|
264
|
+
| Export or option | File |
|
|
265
|
+
| -------------------------------------------------------------- | ------------------------------------------ |
|
|
266
|
+
| `remote`: `name`, `commands` | `src/cli.ts` |
|
|
267
|
+
| `commands`: `stdin`, `positional`, `aliases`, `render`, `name` | `src/cli.ts` |
|
|
268
|
+
| A client from `Config`, its credentials optional | `src/cli.ts` |
|
|
269
|
+
| `make`, with a trusted identity the host supplies | `src/ops.ts` |
|
|
270
|
+
| `logToStderr` | `src/cli.ts`, `src/ops.ts`, `src/stdio.ts` |
|
|
271
|
+
|
|
272
|
+
### Testing
|
|
273
|
+
|
|
274
|
+
| Export or option | File |
|
|
275
|
+
| ------------------------------------------------ | ------------------------------------------------------------- |
|
|
276
|
+
| `layer(routes)` | `test/identity/identity.test.ts` |
|
|
277
|
+
| `layer(routes)`, twice, over shared builders | `test/routes.test.ts` (`shared`) |
|
|
278
|
+
| `layer(handler)`, over `HttpRouter.toWebHandler` | `test/request-policy.test.ts` |
|
|
279
|
+
| `mcpClient`: `transformClient` | `test/routes.test.ts` |
|
|
280
|
+
| `mcpClient`, decoding an image | `test/attachments/attachments.test.ts` |
|
|
281
|
+
| `McpCallError` | `test/identity/identity.test.ts` |
|
|
282
|
+
| `mcpRequest`: `headers`, `url` | `test/attachments/attachments.test.ts`, `test/routes.test.ts` |
|
|
283
|
+
| RPC in memory, over Effect's HTTP protocol | `test/identity/session.test.ts` |
|
|
284
|
+
| A service the test and the routes both read | `test/identity/session.test.ts` (`served`) |
|
|
285
|
+
| A test replacing one service | `test/identity/identity.test.ts` (`servedWith`) |
|
|
286
|
+
| `Action.client`, a caller per call | `test/issues/issues.test.ts` |
|
|
287
|
+
| A scripted `LanguageModel` | `test/issues/assistant.test.ts`, `test/support/scripted.ts` |
|
|
288
|
+
| A test replacing the model the routes are given | `test/assistant/assistant.test.ts` (`served`) |
|
|
289
|
+
| An error's message asserted beside its fields | `test/issues/issues.test.ts` |
|
|
290
|
+
|
|
291
|
+
## Not shown
|
|
292
|
+
|
|
293
|
+
Left out because this application has no honest place for them:
|
|
294
|
+
|
|
295
|
+
- `Authentication.refusalResponse` and `bearerTokenOf`. They serve a caller the router never
|
|
296
|
+
routes, such as a Node `upgrade` handler. Every caller here is routed: the socket is Effect's
|
|
297
|
+
own WebSocket route, authenticated per message.
|
|
298
|
+
- A descriptor with `HttpApiSecurity.basic`, and an API key in the query.
|
|
299
|
+
- `Action.implement` with a plain record of handlers: every implementation of several actions
|
|
300
|
+
here has a builder. `implementation.actions`. `mcp._meta`.
|
|
301
|
+
- `ActionCli.command` and `remoteCommand`, one action as one command; `make`'s and `remote`'s
|
|
302
|
+
`actions`; `remote`'s static `client` option. Both CLIs here are aggregates of every action,
|
|
303
|
+
and the remote one reads its client from `Config`. `subcommands` and `toUserError`, a command
|
|
304
|
+
written by hand: [notes-service](../notes-service/) has one.
|
|
305
|
+
- `ActionRpc.layer`'s `actions`, `Rpc.group`, and `ActionRpc.client`'s options.
|
|
306
|
+
- 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`.
|
|
309
|
+
- `needsApproval` as an Effect reading the caller: the rule here is a boolean of the action.
|
|
310
|
+
- `Action.InvalidInput.fromSchemaError`, the `Action.Refusal` and `Action.BuiltIn` schemas, and the
|
|
311
|
+
helper types: `Action.Issue`, `Errors`, `Handlers`, `Client`, `AnyImplementation`,
|
|
312
|
+
`BuildServices`, `ActionHttp.Client`, `Authentication.Any`.
|
|
313
|
+
- A handler sending MCP notifications through `McpServer.McpServer`, and one reading
|
|
314
|
+
`McpSchema.McpRequestContext`.
|
|
315
|
+
- A tenant taken from a subdomain. The workspace is a header, so the OAuth resource the server
|
|
316
|
+
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`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Assistant } from "./assistant/Assistant.js";
|
|
2
|
+
import { AttachImage, GetAttachment } from "./attachments/Attachments.js";
|
|
3
|
+
import { WhoAmI } from "./identity/Identity.js";
|
|
4
|
+
import { Issues } from "./issues/Issues.js";
|
|
5
|
+
import { GetStatus } from "./workspaces/Workspaces.js";
|
|
6
|
+
|
|
7
|
+
// Which actions each surface gets. A contract says nothing about where it is served: each
|
|
8
|
+
// surface takes a list, and these are the lists, built from the contracts themselves.
|
|
9
|
+
|
|
10
|
+
/** What a signed-in member may call: every protected action of the public port. */
|
|
11
|
+
export const MemberActions = [WhoAmI, ...Issues, AttachImage, GetAttachment, ...Assistant] as const;
|
|
12
|
+
|
|
13
|
+
/**
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
export const Tools = [GetStatus, WhoAmI, ...Issues, GetAttachment] as const;
|