@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.
Files changed (215) hide show
  1. package/README.md +102 -41
  2. package/dist/Action.d.mts +31 -117
  3. package/dist/Action.mjs +14 -53
  4. package/dist/ActionCli.d.mts +60 -28
  5. package/dist/ActionCli.mjs +54 -48
  6. package/dist/ActionHttp.d.mts +18 -67
  7. package/dist/ActionHttp.mjs +20 -70
  8. package/dist/ActionMcp.d.mts +23 -29
  9. package/dist/ActionMcp.mjs +56 -114
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +28 -9
  13. package/dist/ActionToolkit.mjs +31 -4
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +30 -23
  17. package/dist/Testing.mjs +27 -44
  18. package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
  19. package/dist/authentication/provider.mjs +12 -0
  20. package/dist/authentication/refusal.mjs +36 -0
  21. package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
  22. package/dist/cli/commands.mjs +302 -0
  23. package/dist/{internal → contract}/call.d.mts +2 -2
  24. package/dist/contract/call.mjs +10 -0
  25. package/dist/{internal → contract}/errors.d.mts +13 -10
  26. package/dist/{internal → contract}/errors.mjs +9 -14
  27. package/dist/{internal → contract}/implementation.d.mts +37 -21
  28. package/dist/{internal → contract}/implementation.mjs +18 -49
  29. package/dist/contract/media.d.mts +13 -0
  30. package/dist/contract/media.mjs +18 -0
  31. package/dist/contract/rules.d.mts +25 -0
  32. package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
  33. package/dist/{internal → http}/client.d.mts +7 -9
  34. package/dist/http/client.mjs +17 -0
  35. package/dist/http/declared.mjs +20 -0
  36. package/dist/http/schema-errors.mjs +9 -0
  37. package/dist/mcp/protocol.d.mts +18 -0
  38. package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
  39. package/dist/stdio/console.mjs +64 -0
  40. package/dist/{internal → testing}/memory.d.mts +1 -1
  41. package/dist/{internal → testing}/memory.mjs +5 -17
  42. package/dist/toolkit/tools.mjs +29 -0
  43. package/docs/Action-failures.md +47 -0
  44. package/docs/Action-in-process.md +93 -0
  45. package/docs/Action.md +211 -426
  46. package/docs/ActionCli-failures.md +104 -0
  47. package/docs/ActionCli.md +208 -293
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +205 -282
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +222 -271
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +179 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +103 -119
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +143 -392
  59. package/docs/CONTEXT.md +40 -24
  60. package/docs/README.md +75 -35
  61. package/docs/Testing-failures.md +21 -0
  62. package/docs/Testing.md +193 -151
  63. package/docs/guarantees.md +35 -96
  64. package/docs/setup-failures.md +37 -0
  65. package/docs/setup.md +90 -92
  66. package/examples/README.md +68 -0
  67. package/examples/assistant/README.md +92 -0
  68. package/examples/assistant/src/agent.ts +92 -0
  69. package/examples/assistant/src/cli.ts +34 -0
  70. package/examples/assistant/src/commands.ts +25 -0
  71. package/examples/assistant/src/identity/Identity.ts +33 -0
  72. package/examples/assistant/src/identity/authorize.ts +24 -0
  73. package/examples/assistant/src/identity/handlers.ts +10 -0
  74. package/examples/assistant/src/identity/members.ts +9 -0
  75. package/examples/assistant/src/identity/permissions.ts +23 -0
  76. package/examples/assistant/src/scripted.ts +23 -0
  77. package/examples/assistant/src/tasks/Tasks.ts +63 -0
  78. package/examples/assistant/src/tasks/handlers.ts +25 -0
  79. package/examples/assistant/src/tasks/store.ts +96 -0
  80. package/examples/assistant/src/toolkit.ts +30 -0
  81. package/examples/assistant/test/commands.test.ts +57 -0
  82. package/examples/assistant/test/identity/identity.test.ts +43 -0
  83. package/examples/assistant/test/tasks/store.test.ts +48 -0
  84. package/examples/assistant/test/tasks/tasks.test.ts +75 -0
  85. package/examples/assistant/test/toolkit.test.ts +121 -0
  86. package/examples/kitchen-sink/README.md +318 -0
  87. package/examples/kitchen-sink/src/Actions.ts +19 -0
  88. package/examples/kitchen-sink/src/Api.ts +24 -0
  89. package/examples/kitchen-sink/src/Rpc.ts +12 -0
  90. package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
  91. package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
  92. package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
  93. package/examples/kitchen-sink/src/admin/grants.ts +22 -0
  94. package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
  95. package/examples/kitchen-sink/src/admin/keys.ts +8 -0
  96. package/examples/kitchen-sink/src/agent.ts +47 -0
  97. package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
  98. package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
  99. package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
  100. package/examples/kitchen-sink/src/assistant/handlers.ts +126 -0
  101. package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
  102. package/examples/kitchen-sink/src/attachments/download.ts +49 -0
  103. package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
  104. package/examples/kitchen-sink/src/attachments/store.ts +58 -0
  105. package/examples/kitchen-sink/src/browser.ts +33 -0
  106. package/examples/kitchen-sink/src/cli.ts +85 -0
  107. package/examples/kitchen-sink/src/config.ts +18 -0
  108. package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
  109. package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
  110. package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
  111. package/examples/kitchen-sink/src/identity/directory.ts +52 -0
  112. package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
  113. package/examples/kitchen-sink/src/identity/members.ts +13 -0
  114. package/examples/kitchen-sink/src/identity/quota.ts +62 -0
  115. package/examples/kitchen-sink/src/issues/Issues.ts +111 -0
  116. package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
  117. package/examples/kitchen-sink/src/issues/guide.ts +37 -0
  118. package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
  119. package/examples/kitchen-sink/src/issues/store.ts +96 -0
  120. package/examples/kitchen-sink/src/issues/write-limit.ts +42 -0
  121. package/examples/kitchen-sink/src/memory.ts +30 -0
  122. package/examples/kitchen-sink/src/ops.ts +42 -0
  123. package/examples/kitchen-sink/src/request-policy.ts +63 -0
  124. package/examples/kitchen-sink/src/routes.ts +120 -0
  125. package/examples/kitchen-sink/src/sdk.ts +46 -0
  126. package/examples/kitchen-sink/src/seed.ts +65 -0
  127. package/examples/kitchen-sink/src/server.ts +62 -0
  128. package/examples/kitchen-sink/src/stdio.ts +34 -0
  129. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
  130. package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
  131. package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
  132. package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
  133. package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
  134. package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
  135. package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
  136. package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
  137. package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
  138. package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
  139. package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
  140. package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
  141. package/examples/kitchen-sink/test/routes.test.ts +123 -0
  142. package/examples/kitchen-sink/test/support/scripted.ts +40 -0
  143. package/examples/live-rpc/README.md +107 -0
  144. package/examples/live-rpc/src/Rpc.ts +8 -0
  145. package/examples/live-rpc/src/browser.ts +98 -0
  146. package/examples/live-rpc/src/chat/Chat.ts +77 -0
  147. package/examples/live-rpc/src/chat/authorize.ts +16 -0
  148. package/examples/live-rpc/src/chat/handlers.ts +50 -0
  149. package/examples/live-rpc/src/chat/room.ts +23 -0
  150. package/examples/live-rpc/src/chat/store.ts +54 -0
  151. package/examples/live-rpc/src/client.ts +37 -0
  152. package/examples/live-rpc/src/identity/Identity.ts +19 -0
  153. package/examples/live-rpc/src/identity/members.ts +8 -0
  154. package/examples/live-rpc/src/identity/verifier.ts +23 -0
  155. package/examples/live-rpc/src/routes.ts +22 -0
  156. package/examples/live-rpc/src/server.ts +43 -0
  157. package/examples/live-rpc/test/browser.test.ts +105 -0
  158. package/examples/live-rpc/test/chat/chat.test.ts +69 -0
  159. package/examples/live-rpc/test/routes.test.ts +99 -0
  160. package/examples/live-rpc/test/server.test.ts +75 -0
  161. package/examples/mcp-tools/README.md +141 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +121 -0
  164. package/examples/mcp-tools/src/printers/fake.ts +115 -0
  165. package/examples/mcp-tools/src/printers/farm.ts +136 -0
  166. package/examples/mcp-tools/src/printers/handlers.ts +29 -0
  167. package/examples/mcp-tools/src/routes.ts +52 -0
  168. package/examples/mcp-tools/src/server.ts +31 -0
  169. package/examples/mcp-tools/src/stdio.ts +26 -0
  170. package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
  171. package/examples/mcp-tools/test/routes.test.ts +202 -0
  172. package/examples/notes-service/README.md +111 -0
  173. package/examples/notes-service/src/Api.ts +13 -0
  174. package/examples/notes-service/src/browser.ts +41 -0
  175. package/examples/notes-service/src/cli.ts +22 -0
  176. package/examples/notes-service/src/commands.ts +137 -0
  177. package/examples/notes-service/src/identity/Identity.ts +31 -0
  178. package/examples/notes-service/src/identity/authorize.ts +18 -0
  179. package/examples/notes-service/src/identity/handlers.ts +10 -0
  180. package/examples/notes-service/src/identity/users.ts +9 -0
  181. package/examples/notes-service/src/identity/verifier.ts +36 -0
  182. package/examples/notes-service/src/notes/Notes.ts +119 -0
  183. package/examples/notes-service/src/notes/handlers.ts +39 -0
  184. package/examples/notes-service/src/notes/store.ts +84 -0
  185. package/examples/notes-service/src/routes.ts +45 -0
  186. package/examples/notes-service/src/server.ts +26 -0
  187. package/examples/notes-service/src/status/Status.ts +10 -0
  188. package/examples/notes-service/src/status/handlers.ts +14 -0
  189. package/examples/notes-service/src/stdio.ts +25 -0
  190. package/examples/notes-service/test/commands.test.ts +288 -0
  191. package/examples/notes-service/test/notes/notes.test.ts +103 -0
  192. package/examples/notes-service/test/routes.test.ts +84 -0
  193. package/examples/quickstart/README.md +31 -0
  194. package/examples/quickstart/src/Api.ts +5 -0
  195. package/examples/quickstart/src/client.ts +13 -0
  196. package/examples/quickstart/src/greeting/Greeting.ts +12 -0
  197. package/examples/quickstart/src/greeting/handlers.ts +6 -0
  198. package/examples/quickstart/src/routes.ts +11 -0
  199. package/examples/quickstart/src/server.ts +16 -0
  200. package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
  201. package/package.json +27 -9
  202. package/dist/internal/actions.d.mts +0 -9
  203. package/dist/internal/authentication.mjs +0 -11
  204. package/dist/internal/call.mjs +0 -12
  205. package/dist/internal/cli.mjs +0 -328
  206. package/dist/internal/client.mjs +0 -25
  207. package/dist/internal/console.mjs +0 -77
  208. package/dist/internal/declared.d.mts +0 -13
  209. package/dist/internal/declared.mjs +0 -34
  210. package/dist/internal/mcp.d.mts +0 -14
  211. package/dist/internal/media.d.mts +0 -8
  212. package/dist/internal/media.mjs +0 -26
  213. package/dist/internal/refusal.mjs +0 -67
  214. package/dist/internal/schema-errors.mjs +0 -20
  215. package/dist/internal/tools.mjs +0 -44
@@ -0,0 +1,24 @@
1
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
2
+ import { MemberActions } from "./Actions.js";
3
+ import { Admin, OperatorKey } from "./admin/Admin.js";
4
+ import { Login, QuotaExceeded } from "./identity/Identity.js";
5
+ import { GetStatus } from "./workspaces/Workspaces.js";
6
+
7
+ // The members' HTTP binding: every action at `POST /api/v1/<name>`. It is plain data and
8
+ // imports no server code, so the server, the CLI, the SDK and a test all import this one value.
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`.
12
+ export const Http = ActionHttp.make([GetStatus, ...MemberActions], {
13
+ prefix: "/api/v1",
14
+ authentication: Login,
15
+ error: QuotaExceeded,
16
+ });
17
+
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.
20
+ // The public `getStatus` is bound here too: one contract, one implementation, on both ports.
21
+ export const AdminHttp = ActionHttp.make([GetStatus, ...Admin], {
22
+ prefix: "/admin",
23
+ authentication: OperatorKey,
24
+ });
@@ -0,0 +1,12 @@
1
+ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
2
+ import { MemberActions } from "./Actions.js";
3
+ import { QuotaExceeded, Session } from "./identity/Identity.js";
4
+
5
+ // The web app's binding: the members' actions as Effect RPC, one native rpc per action, over
6
+ // one WebSocket. Browser-safe, as the HTTP binding is. It names the other descriptor of the
7
+ // same identity: a page proves who it is with its session cookie, which the browser sends on
8
+ // the socket's upgrade. Its `error` is what the layer's middleware, the quota, fails with.
9
+ export const Rpc = ActionRpc.make(MemberActions, {
10
+ authentication: Session,
11
+ error: QuotaExceeded,
12
+ });
@@ -0,0 +1,59 @@
1
+ import { Context, Schema } from "effect";
2
+ import { HttpApiSecurity } from "effect/http-api";
3
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
4
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
5
+ import { Workspace, WorkspaceNotFound } from "../workspaces/Workspaces.js";
6
+
7
+ /** What a verified operator key can name. */
8
+ export interface KeyOperator {
9
+ readonly id: string;
10
+ readonly trusted: false;
11
+ }
12
+
13
+ /** What only a host supplies, on its own console. No verifier returns one, so no key names it. */
14
+ export interface TrustedOperator {
15
+ readonly id: string;
16
+ readonly trusted: true;
17
+ }
18
+
19
+ /**
20
+ * The operators' identity, another audience than the members: its own service, its own
21
+ * descriptor, its own port. Typed in two parts, so the trusted one cannot arrive over the wire.
22
+ */
23
+ export class Operator extends Context.Service<Operator, KeyOperator | TrustedOperator>()(
24
+ "tracker/Operator",
25
+ ) {}
26
+
27
+ // How a remote operator proves it: an API key in a header, a native scheme named by
28
+ // `security`. A key cannot step up, and has no OAuth discovery.
29
+ export const OperatorKey = Authentication.make("tracker.OperatorKey", Operator, {
30
+ security: HttpApiSecurity.apiKey({ in: "header", key: "x-operator-key" }),
31
+ });
32
+
33
+ // What the two actions on one workspace share, their input and success included.
34
+ const onWorkspace = {
35
+ caller: Operator,
36
+ input: { workspace: Schema.String },
37
+ success: Workspace,
38
+ error: WorkspaceNotFound,
39
+ readOnly: false,
40
+ } as const;
41
+
42
+ export const ListWorkspaces = Action.make("listWorkspaces", {
43
+ description: "List every workspace and whether it is suspended.",
44
+ success: { workspaces: Schema.Array(Workspace) },
45
+ readOnly: true,
46
+ caller: Operator,
47
+ });
48
+
49
+ export const SuspendWorkspace = Action.make("suspendWorkspace", {
50
+ ...onWorkspace,
51
+ description: "Suspend a workspace: its members are refused on the public port until resumed.",
52
+ });
53
+
54
+ export const ResumeWorkspace = Action.make("resumeWorkspace", {
55
+ ...onWorkspace,
56
+ description: "Resume a suspended workspace.",
57
+ });
58
+
59
+ export const Admin = [ListWorkspaces, SuspendWorkspace, ResumeWorkspace] as const;
@@ -0,0 +1,18 @@
1
+ import { Effect, Redacted } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
4
+ import { type KeyOperator, OperatorKey } from "./Admin.js";
5
+ import { keys } from "./keys.js";
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.
10
+ const verify = (
11
+ key: Redacted.Redacted<string>,
12
+ ): Effect.Effect<KeyOperator, Action.Unauthenticated> =>
13
+ Effect.fromNullishOr(keys.get(Redacted.value(key))).pipe(
14
+ Effect.mapError(() => new Action.Unauthenticated({ message: "Unknown operator key." })),
15
+ );
16
+
17
+ // Server only: `OperatorKey`'s provider, which the admin port's routes require.
18
+ export const authenticateOperator = Authentication.layer(OperatorKey, verify);
@@ -0,0 +1,27 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { Operator } from "./Admin.js";
4
+ import { type Grant, Grants } from "./grants.js";
5
+
6
+ // An authorizer built from a startup service, as a handler builder builds handlers: the
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.
10
+ export const authorize = Effect.gen(function* () {
11
+ const grants = yield* Grants;
12
+
13
+ return (action: Action.Any) =>
14
+ Effect.gen(function* () {
15
+ const operator = yield* Operator;
16
+
17
+ // The one rule admits the console's operator too: no surface has a bypass of its own.
18
+ if (operator.trusted) return;
19
+
20
+ const grant: Grant = action.readOnly ? "workspaces:read" : "workspaces:suspend";
21
+
22
+ // No `scopes`: a key cannot be re-authorized, so this is a plain 403.
23
+ if (!(yield* grants.of(operator.id)).includes(grant)) {
24
+ return yield* new Action.Forbidden({ message: `Requires the ${grant} grant.` });
25
+ }
26
+ });
27
+ });
@@ -0,0 +1,22 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+
3
+ export type Grant = "workspaces:read" | "workspaces:suspend";
4
+
5
+ /**
6
+ * What each operator may do. An API key carries no scopes, so the grants are kept here, on the
7
+ * server, and the authorizer looks them up.
8
+ */
9
+ export class Grants extends Context.Service<
10
+ Grants,
11
+ { readonly of: (operator: string) => Effect.Effect<ReadonlyArray<Grant>> }
12
+ >()("tracker/Grants") {
13
+ // DEMO ONLY: fixed grants. A real application reads them from its database.
14
+ static readonly layerDemo = Layer.sync(Grants, () => {
15
+ const grants = new Map<string, ReadonlyArray<Grant>>([
16
+ ["ops", ["workspaces:read", "workspaces:suspend"]],
17
+ ["support", ["workspaces:read"]],
18
+ ]);
19
+
20
+ return Grants.of({ of: (operator) => Effect.sync(() => grants.get(operator) ?? []) });
21
+ });
22
+ }
@@ -0,0 +1,21 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { WorkspaceRegistry } from "../workspaces/registry.js";
4
+ import { Admin } from "./Admin.js";
5
+ import { authorize } from "./authorize.js";
6
+
7
+ // The operators' actions. `authorize` is an Effect that builds the rule, so this
8
+ // implementation owes `Grants` at startup, beside the registry its builder yields.
9
+ export const admin = Action.implement(
10
+ Admin,
11
+ Effect.gen(function* () {
12
+ const registry = yield* WorkspaceRegistry;
13
+
14
+ return {
15
+ listWorkspaces: () => Effect.map(registry.list, (workspaces) => ({ workspaces })),
16
+ suspendWorkspace: ({ workspace }) => registry.setSuspended(workspace, true),
17
+ resumeWorkspace: ({ workspace }) => registry.setSuspended(workspace, false),
18
+ };
19
+ }),
20
+ { authorize },
21
+ );
@@ -0,0 +1,8 @@
1
+ import type { KeyOperator } from "./Admin.js";
2
+
3
+ // DEMO ONLY: two fixed operator keys. A real application has no such table: it stores a hash
4
+ // of each key it issued, and looks the operator up by it.
5
+ export const keys = new Map<string, KeyOperator>([
6
+ ["ops-key", { id: "ops", trusted: false }],
7
+ ["support-key", { id: "support", trusted: false }],
8
+ ]);
@@ -0,0 +1,47 @@
1
+ import { NodeRuntime } from "@effect/platform-node";
2
+ import { Console, Effect, Layer } from "effect";
3
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
4
+ import { demoModel } from "./assistant/demo-model.js";
5
+ import { CurrentMember } from "./identity/Identity.js";
6
+ import { members } from "./identity/members.js";
7
+ import { layer, triage } from "./issues/assistant.js";
8
+ import { issues } from "./issues/handlers.js";
9
+ import { IssueStore } from "./issues/store.js";
10
+ import { limiter } from "./memory.js";
11
+
12
+ // Entrypoint: `node --import tsx examples/kitchen-sink/src/agent.ts`.
13
+ // The assistant as a local program: a model calling the issue actions as tools, in process.
14
+ // The server serves the same tools behind a route, a turn per request: assistant/handlers.ts.
15
+
16
+ const program = Effect.gen(function* () {
17
+ // Code of your own calls the implementation typed, with `Action.client`: the same decoding,
18
+ // `authorize` and handler, with no transport. The store is memory, so file an issue first.
19
+ const tracker = yield* Action.client(issues);
20
+
21
+ yield* tracker.createIssue({ title: "Crash on save", body: "Stack trace attached." });
22
+
23
+ const turn = yield* triage("Close what is done.");
24
+
25
+ // The read ran: its result is here. The write did not: it waits for the member's approval,
26
+ // which a host answers in the next turn, as assistant/handlers.ts does.
27
+ for (const { name, encodedResult } of turn.toolResults) {
28
+ yield* Console.log(`${name} answered`, encodedResult);
29
+ }
30
+
31
+ for (const part of turn.content) {
32
+ if (part.type === "tool-approval-request") {
33
+ yield* Console.log(`Call ${part.toolCallId} waits for approval.`);
34
+ }
35
+ }
36
+ });
37
+
38
+ program.pipe(
39
+ Effect.scoped,
40
+ // No remote caller: this host names the member the assistant acts for, around the turn.
41
+ Effect.provideService(CurrentMember, members.ada),
42
+ // Startup services only. An identity provided here would be every caller's.
43
+ Effect.provide(layer.pipe(Layer.provideMerge(Layer.merge(IssueStore.layerMemory, limiter)))),
44
+ // DEMO ONLY: a scripted model. A real one is a provider's layer, in its place.
45
+ Effect.provide(demoModel),
46
+ NodeRuntime.runMain,
47
+ );
@@ -0,0 +1,82 @@
1
+ import { Schema } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { CurrentMember } from "../identity/Identity.js";
4
+
5
+ // The assistant behind a route: a model working on the issues with the tools of
6
+ // issues/assistant.ts, one request per turn of a conversation. A request starts a turn and its
7
+ // answer is the turn's end: the model's text, or the calls that wait for the member's approval.
8
+
9
+ /**
10
+ * What a turn ended with. A call in `ran` ran in this turn, `failed` where it was refused or
11
+ * failed with a declared error; one the member denied, or one in a reply the provider cut
12
+ * short, did not run and is not listed. A call in `waiting` has not run: `answerAssistant`
13
+ * decides it. `waiting` holds every open request of the conversation: one this turn
14
+ * asked, and one an earlier answer left unanswered, until it is answered.
15
+ */
16
+ export const Turn = Schema.Struct({
17
+ conversation: Schema.String,
18
+ text: Schema.String,
19
+ ran: Schema.Array(Schema.Struct({ tool: Schema.String, failed: Schema.Boolean })),
20
+ waiting: Schema.Array(
21
+ Schema.Struct({
22
+ approvalId: Schema.String,
23
+ tool: Schema.String,
24
+ // The arguments the model gave the call, as JSON text: what the member approves.
25
+ arguments: Schema.String,
26
+ }),
27
+ ),
28
+ });
29
+
30
+ // Another member's conversation is not found either: an id tells nothing of who holds it.
31
+ export class ConversationNotFound extends Schema.TaggedError<ConversationNotFound>()(
32
+ "ConversationNotFound",
33
+ { conversation: Schema.String },
34
+ { httpApiStatus: 404 },
35
+ ) {}
36
+
37
+ // The model failed in the middle of a turn. The conversation is over: its history holds
38
+ // nothing of that turn, so it no longer says which calls ran.
39
+ export class AssistantFailed extends Schema.TaggedError<AssistantFailed>()(
40
+ "AssistantFailed",
41
+ { message: Schema.String },
42
+ { httpApiStatus: 502 },
43
+ ) {}
44
+
45
+ export const AskAssistant = Action.make("askAssistant", {
46
+ description:
47
+ "Ask the assistant about your workspace's issues, in a new conversation or one of yours.",
48
+ input: {
49
+ prompt: Schema.String.check(Schema.isMinLength(1)),
50
+ conversation: Schema.optional(Schema.String).annotate({
51
+ description: "The conversation to continue. Left out, a new one starts.",
52
+ }),
53
+ },
54
+ success: Turn,
55
+ error: [ConversationNotFound, AssistantFailed],
56
+ readOnly: false,
57
+ caller: CurrentMember,
58
+ });
59
+
60
+ export const AnswerAssistant = Action.make("answerAssistant", {
61
+ description: "Approve or deny the calls a turn of your conversation waits for.",
62
+ input: {
63
+ conversation: Schema.String,
64
+ // Each request once. Effect acts on every answer it is given: an `approvalId` approved
65
+ // twice in one request would run its call twice.
66
+ answers: Schema.Array(
67
+ Schema.Struct({ approvalId: Schema.String, approved: Schema.Boolean }),
68
+ ).check(
69
+ Schema.makeFilter(
70
+ (answers) =>
71
+ new Set(answers.map(({ approvalId }) => approvalId)).size === answers.length ||
72
+ "Answer each approvalId once",
73
+ ),
74
+ ),
75
+ },
76
+ success: Turn,
77
+ error: [ConversationNotFound, AssistantFailed],
78
+ readOnly: false,
79
+ caller: CurrentMember,
80
+ });
81
+
82
+ export const Assistant = [AskAssistant, AnswerAssistant] as const;
@@ -0,0 +1,71 @@
1
+ import { Context, Effect, Layer, Semaphore } from "effect";
2
+ import { Chat } from "effect/ai";
3
+ import type { Member } from "../identity/Identity.js";
4
+ import { ConversationNotFound } from "./Assistant.js";
5
+
6
+ interface Conversation {
7
+ /** Effect's `Chat`: the history a model reads, in which an approval's answer finds its request. */
8
+ readonly chat: Chat.Chat;
9
+ readonly lock: Semaphore.Semaphore;
10
+ }
11
+
12
+ /**
13
+ * Where conversations are kept between requests, each with the member it belongs to. A turn
14
+ * names the member, so only its owner reaches a conversation: an approval is acted on as the
15
+ * turn's caller, and must come from the member who was asked.
16
+ */
17
+ export class Conversations extends Context.Service<
18
+ Conversations,
19
+ {
20
+ /** Start a conversation of `owner`'s, and return its id. */
21
+ readonly open: (owner: Member) => Effect.Effect<string>;
22
+ /**
23
+ * Run one turn on a conversation's `Chat`. One turn runs at a time, and a turn that fails or
24
+ * is interrupted ends its conversation: the `Chat` records a turn only when it succeeds, so
25
+ * after any other end its history no longer says which calls ran, and an approval answered
26
+ * in that turn would run again.
27
+ */
28
+ readonly turn: <A, E, R>(
29
+ owner: Member,
30
+ id: string,
31
+ run: (chat: Chat.Chat) => Effect.Effect<A, E, R>,
32
+ ) => Effect.Effect<A, E | ConversationNotFound, R>;
33
+ }
34
+ >()("tracker/Conversations") {
35
+ // DEMO ONLY: memory, lost when the process stops and never trimmed. A real store keeps each
36
+ // history where every instance reads it, `chat.exportJson` to save and `Chat.fromJson` to load.
37
+ static readonly layerMemory = Layer.sync(Conversations, () => {
38
+ const kept = new Map<string, Conversation>();
39
+
40
+ // A member is a person in a workspace, and an id is unguessable: both are in the key, as
41
+ // JSON, which keeps its three parts apart whatever they hold.
42
+ const keyOf = (owner: Member, id: string) => JSON.stringify([owner.workspace, owner.id, id]);
43
+
44
+ // Read when it runs, not when it is written: the second lookup of a turn runs under the lock.
45
+ const find = (key: string, id: string) =>
46
+ Effect.suspend(() => Effect.fromNullishOr(kept.get(key))).pipe(
47
+ Effect.mapError(() => new ConversationNotFound({ conversation: id })),
48
+ );
49
+
50
+ return Conversations.of({
51
+ open: Effect.fn("Conversations.open")(function* (owner) {
52
+ const id = crypto.randomUUID();
53
+
54
+ kept.set(keyOf(owner, id), { chat: yield* Chat.empty, lock: yield* Semaphore.make(1) });
55
+
56
+ return id;
57
+ }),
58
+ turn: (owner, id, run) => {
59
+ const key = keyOf(owner, id);
60
+
61
+ return Effect.flatMap(find(key, id), ({ lock }) =>
62
+ // Looked up again once the lock is held: a turn that waited for one that failed
63
+ // must find its conversation gone, not run on the history that turn left behind.
64
+ Effect.flatMap(find(key, id), ({ chat }) =>
65
+ Effect.onError(run(chat), () => Effect.sync(() => kept.delete(key))),
66
+ ).pipe(lock.withPermits(1)),
67
+ );
68
+ },
69
+ });
70
+ });
71
+ }
@@ -0,0 +1,33 @@
1
+ import { Layer, Effect, Stream } from "effect";
2
+ import { LanguageModel } from "effect/ai";
3
+
4
+ // DEMO ONLY: a model that needs no API key. Asked by a person, it lists the open issues and
5
+ // closes ACME-1; once its tools have answered, it says so. A real one is a provider's layer,
6
+ // such as `AnthropicLanguageModel.model(...)`, provided in its place. Each call has an id of
7
+ // its own, as a provider's has: Effect finds the call an approval is about by that id, so one
8
+ // used twice in a conversation would run another call than the member was shown, or none.
9
+ export const demoModel = Layer.effect(
10
+ LanguageModel.LanguageModel,
11
+ LanguageModel.make({
12
+ generateText: ({ prompt }) =>
13
+ Effect.sync(() =>
14
+ prompt.content.at(-1)?.role === "tool"
15
+ ? [{ type: "text", text: "Done." } as const]
16
+ : [
17
+ {
18
+ type: "tool-call",
19
+ id: crypto.randomUUID(),
20
+ name: "listIssues",
21
+ params: { status: "open" },
22
+ } as const,
23
+ {
24
+ type: "tool-call",
25
+ id: crypto.randomUUID(),
26
+ name: "closeIssue",
27
+ params: { id: "ACME-1" },
28
+ } as const,
29
+ ],
30
+ ),
31
+ streamText: () => Stream.empty,
32
+ }),
33
+ );
@@ -0,0 +1,126 @@
1
+ import { Effect, Ref, Schema } from "effect";
2
+ import { type Chat, LanguageModel, type Prompt } from "effect/ai";
3
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
4
+ import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
5
+ import { CurrentMember } from "../identity/Identity.js";
6
+ import { toolkit } from "../issues/assistant.js";
7
+ import { AssistantFailed, Assistant } from "./Assistant.js";
8
+ import { Conversations } from "./conversations.js";
9
+
10
+ // A model that keeps calling tools is asked this many times in one turn, and no more.
11
+ const requestsPerTurn = 6;
12
+
13
+ type Response = LanguageModel.GenerateTextResponse<typeof toolkit.tools, "opaque">;
14
+
15
+ const approvalsOf = (response: Response) =>
16
+ response.content.filter((part) => part.type === "tool-approval-request");
17
+
18
+ // What Effect gives the model as the result of a call that did not run: one its member denied,
19
+ // or one in a reply the provider cut short. Left out of `ran` below, which lists the calls that
20
+ // ran.
21
+ const didNotRun = Schema.is(
22
+ Schema.Struct({ type: Schema.Literals(["execution-denied", "execution-interrupted"]) }),
23
+ );
24
+
25
+ const callsToRead = (response: Response) =>
26
+ response.toolCalls.length > 0 && approvalsOf(response).length === 0;
27
+
28
+ // The model loop behind a route. The builder runs once, at startup, and yields what a turn is
29
+ // built from: the tools with their handlers, the model, and where conversations are kept. A
30
+ // handler yielding them would owe them per request, which no route provides. `CurrentMember`
31
+ // is the one per-request service: each tool call of a turn runs as the request's caller,
32
+ // through that tool's own `authorize`. A conversation keeps its owner's id, never the member
33
+ // itself, whose scopes would go stale between requests.
34
+ export const assistant = Action.implement(
35
+ Assistant,
36
+ Effect.gen(function* () {
37
+ const tools = yield* toolkit;
38
+ const model = yield* LanguageModel.LanguageModel;
39
+ const conversations = yield* Conversations;
40
+
41
+ const converse = (conversation: string, chat: Chat.Chat, prompt: Prompt.RawInput) =>
42
+ Effect.gen(function* () {
43
+ // One `generateText` is one request to the model: its tool calls run, and their
44
+ // results come back unread. The model is asked again, with nothing new, while it
45
+ // called tools and none of them waits for approval. Continue on tool calls, never on
46
+ // tool results: the turn answering an approval holds the approved call's result and
47
+ // the model's reply to it.
48
+ let response = yield* chat.generateText({ prompt, toolkit: tools });
49
+ const results = [...response.toolResults];
50
+
51
+ for (let asked = 1; asked < requestsPerTurn && callsToRead(response); asked++) {
52
+ response = yield* chat.generateText({ prompt: [], toolkit: tools });
53
+ results.push(...response.toolResults);
54
+ }
55
+
56
+ return {
57
+ conversation,
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
+ ran: results
62
+ .filter(({ result }) => !didNotRun(result))
63
+ .map(({ name, isFailure }) => ({ tool: name, failed: isFailure })),
64
+ // Every request of the conversation still waiting for an answer, with the call it
65
+ // would run, read from the `Chat`'s history by the rule Effect applies when an answer
66
+ // arrives: one an earlier answer left open is listed in every turn until answered.
67
+ // The arguments are shown as JSON, as the member approves them.
68
+ waiting: ActionToolkit.openApprovals(yield* Ref.get(chat.history)).map(
69
+ ({ approvalId, name, params }) => ({
70
+ approvalId,
71
+ tool: name,
72
+ arguments: JSON.stringify(params),
73
+ }),
74
+ ),
75
+ };
76
+ }).pipe(
77
+ // The model is a startup service, given to each turn here.
78
+ Effect.provideService(LanguageModel.LanguageModel, model),
79
+ );
80
+
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.
85
+ const turn = (conversation: string, prompt: Prompt.RawInput) =>
86
+ Effect.flatMap(CurrentMember, (member) =>
87
+ conversations.turn(member, conversation, (chat) => converse(conversation, chat, prompt)),
88
+ ).pipe(
89
+ Effect.catchTag("AiError", () =>
90
+ Effect.fail(
91
+ new AssistantFailed({
92
+ message:
93
+ "The model failed and this conversation has ended. A call you approved in this turn may have run: read the issue before asking again.",
94
+ }),
95
+ ),
96
+ ),
97
+ );
98
+
99
+ return {
100
+ askAssistant: ({ prompt, conversation }) =>
101
+ conversation === undefined
102
+ ? Effect.flatMap(Effect.flatMap(CurrentMember, conversations.open), (opened) =>
103
+ turn(opened, prompt),
104
+ )
105
+ : turn(conversation, prompt),
106
+ // The answers start the next turn, in which an approved call runs, as this request's
107
+ // caller, before the model is asked again. Only the member who was asked reaches the
108
+ // conversation, and a turn that fails ends it, so no answer is left without its result:
109
+ // an `approvalId` that `openApprovals` would not list runs nothing.
110
+ answerAssistant: ({ conversation, answers }) =>
111
+ turn(conversation, [
112
+ {
113
+ role: "tool",
114
+ content: answers.map(({ approvalId, approved }) => ({
115
+ type: "tool-approval-response" as const,
116
+ approvalId,
117
+ approved,
118
+ })),
119
+ },
120
+ ]),
121
+ };
122
+ }),
123
+ // Any member may ask. What the assistant may do for them is each tool's rule, not this one:
124
+ // a member without the write scope gets the tool's refusal as a result the model reads.
125
+ { authorize: Action.allowAll },
126
+ );
@@ -0,0 +1,48 @@
1
+ import { Schema } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { CurrentMember } from "../identity/Identity.js";
4
+ import { IssueWriteErrors } from "../issues/Issues.js";
5
+
6
+ export const Attachment = Schema.Struct({
7
+ id: Schema.String,
8
+ issue: Schema.String,
9
+ name: Schema.String,
10
+ mimeType: Schema.String,
11
+ size: Schema.Finite,
12
+ });
13
+
14
+ export type Attachment = typeof Attachment.Type;
15
+
16
+ export class AttachmentNotFound extends Schema.TaggedError<AttachmentNotFound>()(
17
+ "AttachmentNotFound",
18
+ { id: Schema.String },
19
+ { httpApiStatus: 404 },
20
+ ) {}
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.
25
+ export const AttachImage = Action.make("attachImage", {
26
+ description: "Attach an image, such as a screenshot, to an issue.",
27
+ input: { issue: Schema.String, name: Schema.String, image: Action.Image },
28
+ success: Attachment,
29
+ // A write to an issue as a comment is: the errors those declare, the write limit's included.
30
+ error: IssueWriteErrors,
31
+ readOnly: false,
32
+ caller: CurrentMember,
33
+ });
34
+
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.
39
+ export const GetAttachment = Action.make("getAttachment", {
40
+ description: "Fetch an attachment: its name and size, and the image itself.",
41
+ input: { id: Schema.String },
42
+ success: { attachment: Attachment, image: Action.Image },
43
+ error: AttachmentNotFound,
44
+ readOnly: true,
45
+ caller: CurrentMember,
46
+ });
47
+
48
+ export const Attachments = [AttachImage, GetAttachment] as const;
@@ -0,0 +1,49 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/http";
3
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
4
+ import { authorize } from "../identity/authorize.js";
5
+ import { CurrentMember, Session } from "../identity/Identity.js";
6
+ import { GetAttachment } from "./Attachments.js";
7
+ import { AttachmentStore } from "./store.js";
8
+
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.
15
+ export const download = Layer.unwrap(
16
+ Effect.map(AttachmentStore, (store) =>
17
+ HttpRouter.add(
18
+ "GET",
19
+ "/attachments/:id",
20
+ Effect.gen(function* () {
21
+ // The route states its own authorization. It reuses the rule of the action it mirrors.
22
+ yield* authorize(GetAttachment);
23
+
24
+ const member = yield* CurrentMember;
25
+ const { id = "" } = yield* HttpRouter.params;
26
+ const { image } = yield* store.get(member.workspace, id);
27
+
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`.
30
+ return HttpServerResponse.uint8Array(image.data, {
31
+ contentType: image.mimeType,
32
+ headers: {
33
+ "cache-control": "private, max-age=31536000, immutable",
34
+ // What a member uploaded is served from the application's own origin, and an image
35
+ // can be a document: an SVG opened by its URL, not in an `<img>`, runs its scripts
36
+ // as the member reading it. `sandbox` gives it no origin and no scripts, and
37
+ // `nosniff` keeps the browser to the type stated.
38
+ "content-security-policy": "sandbox",
39
+ "x-content-type-options": "nosniff",
40
+ },
41
+ });
42
+ }).pipe(
43
+ Effect.catchTag("AttachmentNotFound", () =>
44
+ Effect.succeed(HttpServerResponse.empty({ status: 404 })),
45
+ ),
46
+ ),
47
+ ).pipe(Layer.provide(Authentication.protect(Session).layer)),
48
+ ),
49
+ );