@gjermundgaraba/effect-actions 0.19.0 → 0.21.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 (326) hide show
  1. package/README.md +19 -41
  2. package/dist/Action.d.mts +140 -48
  3. package/dist/Action.mjs +88 -44
  4. package/dist/ActionCli.d.mts +70 -35
  5. package/dist/ActionCli.mjs +53 -34
  6. package/dist/ActionHttp.d.mts +70 -38
  7. package/dist/ActionHttp.mjs +76 -25
  8. package/dist/ActionMcp.d.mts +56 -24
  9. package/dist/ActionMcp.mjs +104 -61
  10. package/dist/ActionRpc.d.mts +222 -57
  11. package/dist/ActionRpc.mjs +301 -49
  12. package/dist/ActionToolkit.d.mts +22 -7
  13. package/dist/ActionToolkit.mjs +37 -14
  14. package/dist/Authentication.d.mts +42 -12
  15. package/dist/Authentication.mjs +38 -32
  16. package/dist/Event.d.mts +370 -0
  17. package/dist/Event.mjs +103 -0
  18. package/dist/Testing.d.mts +1 -3
  19. package/dist/Testing.mjs +2 -3
  20. package/dist/Webhook.d.mts +146 -0
  21. package/dist/Webhook.mjs +72 -0
  22. package/dist/authentication/provider.d.mts +35 -12
  23. package/dist/authentication/provider.mjs +2 -3
  24. package/dist/authentication/refusal.mjs +3 -4
  25. package/dist/cli/commands.d.mts +9 -7
  26. package/dist/cli/commands.mjs +85 -35
  27. package/dist/contract/ast.mjs +5 -3
  28. package/dist/contract/authorizer.d.mts +8 -0
  29. package/dist/contract/authorizer.mjs +20 -0
  30. package/dist/contract/call.d.mts +9 -8
  31. package/dist/contract/call.mjs +49 -8
  32. package/dist/contract/callback.d.mts +7 -0
  33. package/dist/contract/callback.mjs +27 -0
  34. package/dist/contract/caller.d.mts +12 -3
  35. package/dist/contract/caller.mjs +23 -5
  36. package/dist/contract/canonical.mjs +10 -0
  37. package/dist/contract/codec.d.mts +9 -0
  38. package/dist/contract/codec.mjs +9 -0
  39. package/dist/contract/codes.mjs +81 -0
  40. package/dist/contract/declared.d.mts +0 -2
  41. package/dist/contract/declared.mjs +26 -11
  42. package/dist/contract/digest.mjs +6 -0
  43. package/dist/contract/errors.d.mts +9 -5
  44. package/dist/contract/errors.mjs +2 -3
  45. package/dist/contract/feed.d.mts +127 -0
  46. package/dist/contract/feed.mjs +249 -0
  47. package/dist/contract/hints.mjs +1 -2
  48. package/dist/contract/hub.d.mts +94 -0
  49. package/dist/contract/hub.mjs +133 -0
  50. package/dist/contract/idempotency.d.mts +47 -0
  51. package/dist/contract/idempotency.mjs +99 -0
  52. package/dist/contract/implementation.d.mts +73 -51
  53. package/dist/contract/implementation.mjs +94 -60
  54. package/dist/contract/input-check.d.mts +15 -0
  55. package/dist/contract/input-check.mjs +16 -0
  56. package/dist/contract/lease.mjs +28 -0
  57. package/dist/contract/listed.d.mts +4 -0
  58. package/dist/contract/listed.mjs +22 -0
  59. package/dist/contract/log.d.mts +41 -0
  60. package/dist/contract/log.mjs +144 -0
  61. package/dist/contract/media.d.mts +1 -3
  62. package/dist/contract/media.mjs +1 -2
  63. package/dist/contract/names.mjs +15 -6
  64. package/dist/contract/occurrence.d.mts +29 -0
  65. package/dist/contract/occurrence.mjs +45 -0
  66. package/dist/contract/pages.d.mts +13 -0
  67. package/dist/contract/pages.mjs +39 -0
  68. package/dist/contract/progress.d.mts +26 -0
  69. package/dist/contract/progress.mjs +72 -0
  70. package/dist/contract/push.d.mts +20 -0
  71. package/dist/contract/push.mjs +96 -0
  72. package/dist/contract/receiving.d.mts +29 -0
  73. package/dist/contract/receiving.mjs +42 -0
  74. package/dist/contract/recent.mjs +12 -0
  75. package/dist/contract/reported.mjs +16 -0
  76. package/dist/contract/serving.mjs +63 -0
  77. package/dist/contract/status.mjs +15 -7
  78. package/dist/contract/stream.d.mts +34 -0
  79. package/dist/contract/stream.mjs +31 -0
  80. package/dist/contract/subscribe.d.mts +45 -0
  81. package/dist/contract/subscribe.mjs +50 -0
  82. package/dist/contract/transient.mjs +28 -0
  83. package/dist/contract/watch.d.mts +18 -0
  84. package/dist/contract/watch.mjs +105 -0
  85. package/dist/contract/webhooks.d.mts +42 -0
  86. package/dist/contract/webhooks.mjs +53 -0
  87. package/dist/http/client.d.mts +37 -14
  88. package/dist/http/client.mjs +52 -14
  89. package/dist/http/events.d.mts +56 -0
  90. package/dist/http/events.mjs +102 -0
  91. package/dist/http/placed.d.mts +36 -0
  92. package/dist/http/placed.mjs +71 -0
  93. package/dist/http/schema-errors.mjs +2 -3
  94. package/dist/http/streamed.d.mts +21 -0
  95. package/dist/http/streamed.mjs +94 -0
  96. package/dist/http/watch.mjs +50 -0
  97. package/dist/mcp/bridge.mjs +283 -0
  98. package/dist/mcp/client.d.mts +30 -99
  99. package/dist/mcp/client.mjs +60 -134
  100. package/dist/mcp/events.mjs +142 -0
  101. package/dist/mcp/progress.mjs +34 -0
  102. package/dist/mcp/protocol.mjs +40 -15
  103. package/dist/mcp/reply.d.mts +79 -0
  104. package/dist/mcp/reply.mjs +99 -0
  105. package/dist/mcp/request.d.mts +40 -0
  106. package/dist/mcp/request.mjs +51 -0
  107. package/dist/mcp/watch.mjs +115 -0
  108. package/dist/rpc/watch.mjs +39 -0
  109. package/dist/stdio/console.mjs +14 -7
  110. package/dist/testing/memory.d.mts +1 -3
  111. package/dist/testing/memory.mjs +4 -5
  112. package/dist/webhook/address.mjs +47 -0
  113. package/dist/webhook/attempt.mjs +65 -0
  114. package/dist/webhook/receiver.mjs +67 -0
  115. package/dist/webhook/signing.mjs +28 -0
  116. package/dist/webhook/stored.mjs +57 -0
  117. package/dist/webhook/subscriptions.mjs +342 -0
  118. package/docs/Action-failures.md +2 -2
  119. package/docs/Action-streams.md +115 -0
  120. package/docs/Action.md +58 -124
  121. package/docs/ActionCli-failures.md +1 -2
  122. package/docs/ActionCli.md +83 -88
  123. package/docs/ActionHttp-failures.md +3 -3
  124. package/docs/ActionHttp.md +90 -70
  125. package/docs/ActionMcp-failures.md +2 -1
  126. package/docs/ActionMcp.md +120 -92
  127. package/docs/ActionRpc-events.md +126 -0
  128. package/docs/ActionRpc-failures.md +1 -2
  129. package/docs/ActionRpc.md +108 -79
  130. package/docs/ActionToolkit-failures.md +1 -2
  131. package/docs/ActionToolkit.md +33 -90
  132. package/docs/Authentication-advanced.md +57 -61
  133. package/docs/Authentication-failures.md +5 -4
  134. package/docs/Authentication.md +23 -58
  135. package/docs/Event-failures.md +15 -0
  136. package/docs/Event.md +281 -0
  137. package/docs/README.md +51 -82
  138. package/docs/Testing-failures.md +1 -5
  139. package/docs/Testing.md +126 -86
  140. package/docs/Webhook-failures.md +8 -0
  141. package/docs/Webhook.md +319 -0
  142. package/docs/setup-failures.md +2 -3
  143. package/docs/setup.md +32 -53
  144. package/examples/README.md +12 -37
  145. package/examples/assistant/README.md +21 -26
  146. package/examples/assistant/src/agent.ts +66 -24
  147. package/examples/assistant/src/cli.ts +7 -4
  148. package/examples/assistant/src/commands.ts +9 -4
  149. package/examples/assistant/src/identity/Identity.ts +5 -4
  150. package/examples/assistant/src/identity/authorize.ts +13 -5
  151. package/examples/assistant/src/identity/handlers.ts +3 -2
  152. package/examples/assistant/src/identity/permissions.ts +9 -3
  153. package/examples/assistant/src/platform/tasks-file.ts +15 -11
  154. package/examples/assistant/src/scripted.ts +7 -4
  155. package/examples/assistant/src/tasks/Tasks.ts +20 -6
  156. package/examples/assistant/src/tasks/handlers.ts +9 -5
  157. package/examples/assistant/src/tasks/store.ts +38 -11
  158. package/examples/assistant/src/toolkit.ts +11 -7
  159. package/examples/assistant/test/commands.test.ts +53 -27
  160. package/examples/assistant/test/identity/identity.test.ts +27 -11
  161. package/examples/assistant/test/platform/tasks-file.test.ts +39 -23
  162. package/examples/assistant/test/tasks/tasks.test.ts +62 -34
  163. package/examples/assistant/test/toolkit.test.ts +104 -75
  164. package/examples/build-events/README.md +83 -0
  165. package/examples/build-events/src/Api.ts +27 -0
  166. package/examples/build-events/src/Rpc.ts +26 -0
  167. package/examples/build-events/src/builds/Builds.ts +108 -0
  168. package/examples/build-events/src/builds/events.ts +54 -0
  169. package/examples/build-events/src/builds/handlers.ts +58 -0
  170. package/examples/build-events/src/builds/log.ts +30 -0
  171. package/examples/build-events/src/builds/status.ts +28 -0
  172. package/examples/build-events/src/builds/store.ts +130 -0
  173. package/examples/build-events/src/cli.ts +18 -0
  174. package/examples/build-events/src/commands.ts +32 -0
  175. package/examples/build-events/src/deploys/Deploys.ts +44 -0
  176. package/examples/build-events/src/deploys/approvers.ts +55 -0
  177. package/examples/build-events/src/deploys/events.ts +32 -0
  178. package/examples/build-events/src/deploys/handlers.ts +38 -0
  179. package/examples/build-events/src/identity/Identity.ts +29 -0
  180. package/examples/build-events/src/identity/members.ts +7 -0
  181. package/examples/build-events/src/identity/verifier.ts +22 -0
  182. package/examples/build-events/src/notifier/Notices.ts +26 -0
  183. package/examples/build-events/src/notifier/routes.ts +71 -0
  184. package/examples/build-events/src/notifier.ts +48 -0
  185. package/examples/build-events/src/routes.ts +71 -0
  186. package/examples/build-events/src/server.ts +62 -0
  187. package/examples/build-events/test/builds/events.test.ts +68 -0
  188. package/examples/build-events/test/builds/log.test.ts +71 -0
  189. package/examples/build-events/test/builds/progress.test.ts +57 -0
  190. package/examples/build-events/test/builds/status.test.ts +42 -0
  191. package/examples/build-events/test/commands.test.ts +103 -0
  192. package/examples/build-events/test/deploys/events.test.ts +52 -0
  193. package/examples/build-events/test/http.test.ts +95 -0
  194. package/examples/build-events/test/notifier.test.ts +80 -0
  195. package/examples/build-events/test/routes.test.ts +96 -0
  196. package/examples/build-events/test/rpc.test.ts +84 -0
  197. package/examples/build-events/test/support/as.ts +8 -0
  198. package/examples/build-events/test/support/memory.ts +43 -0
  199. package/examples/build-events/test/webhooks.test.ts +143 -0
  200. package/examples/kitchen-sink/README.md +178 -166
  201. package/examples/kitchen-sink/src/Actions.ts +11 -3
  202. package/examples/kitchen-sink/src/Api.ts +12 -6
  203. package/examples/kitchen-sink/src/Rpc.ts +7 -5
  204. package/examples/kitchen-sink/src/admin/Admin.ts +21 -10
  205. package/examples/kitchen-sink/src/admin/authenticate.ts +8 -4
  206. package/examples/kitchen-sink/src/admin/authorize.ts +13 -5
  207. package/examples/kitchen-sink/src/admin/grants.ts +5 -3
  208. package/examples/kitchen-sink/src/admin/handlers.ts +9 -5
  209. package/examples/kitchen-sink/src/agent.ts +14 -6
  210. package/examples/kitchen-sink/src/assistant/Assistant.ts +21 -15
  211. package/examples/kitchen-sink/src/assistant/conversations.ts +14 -9
  212. package/examples/kitchen-sink/src/assistant/demo-model.ts +2 -2
  213. package/examples/kitchen-sink/src/assistant/handlers.ts +42 -22
  214. package/examples/kitchen-sink/src/attachments/Attachments.ts +6 -6
  215. package/examples/kitchen-sink/src/attachments/download.ts +7 -6
  216. package/examples/kitchen-sink/src/attachments/handlers.ts +9 -4
  217. package/examples/kitchen-sink/src/attachments/store.ts +25 -18
  218. package/examples/kitchen-sink/src/browser.ts +10 -4
  219. package/examples/kitchen-sink/src/cli.ts +28 -12
  220. package/examples/kitchen-sink/src/config.ts +1 -1
  221. package/examples/kitchen-sink/src/identity/Identity.ts +24 -10
  222. package/examples/kitchen-sink/src/identity/authenticate.ts +19 -11
  223. package/examples/kitchen-sink/src/identity/authorize.ts +14 -4
  224. package/examples/kitchen-sink/src/identity/directory.ts +16 -7
  225. package/examples/kitchen-sink/src/identity/handlers.ts +1 -0
  226. package/examples/kitchen-sink/src/identity/members.ts +7 -2
  227. package/examples/kitchen-sink/src/identity/quota.ts +23 -20
  228. package/examples/kitchen-sink/src/issues/Issues.ts +91 -15
  229. package/examples/kitchen-sink/src/issues/assistant.ts +4 -2
  230. package/examples/kitchen-sink/src/issues/events.ts +46 -0
  231. package/examples/kitchen-sink/src/issues/export.ts +35 -0
  232. package/examples/kitchen-sink/src/issues/guide.ts +4 -3
  233. package/examples/kitchen-sink/src/issues/handlers.ts +20 -7
  234. package/examples/kitchen-sink/src/issues/store.ts +156 -67
  235. package/examples/kitchen-sink/src/issues/write-limit.ts +44 -18
  236. package/examples/kitchen-sink/src/memory.ts +11 -2
  237. package/examples/kitchen-sink/src/ops.ts +10 -5
  238. package/examples/kitchen-sink/src/request-policy.ts +25 -9
  239. package/examples/kitchen-sink/src/routes.ts +67 -27
  240. package/examples/kitchen-sink/src/sdk.ts +22 -12
  241. package/examples/kitchen-sink/src/seed.ts +15 -9
  242. package/examples/kitchen-sink/src/server.ts +24 -10
  243. package/examples/kitchen-sink/src/stdio.ts +9 -3
  244. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +10 -7
  245. package/examples/kitchen-sink/src/workspaces/handlers.ts +3 -2
  246. package/examples/kitchen-sink/src/workspaces/registry.ts +16 -10
  247. package/examples/kitchen-sink/src/workspaces/tenant.ts +12 -5
  248. package/examples/kitchen-sink/test/admin/admin.test.ts +38 -23
  249. package/examples/kitchen-sink/test/assistant/assistant.test.ts +471 -299
  250. package/examples/kitchen-sink/test/attachments/attachments.test.ts +135 -94
  251. package/examples/kitchen-sink/test/identity/identity.test.ts +110 -82
  252. package/examples/kitchen-sink/test/identity/session.test.ts +65 -43
  253. package/examples/kitchen-sink/test/issues/assistant.test.ts +50 -31
  254. package/examples/kitchen-sink/test/issues/events.test.ts +273 -0
  255. package/examples/kitchen-sink/test/issues/export.test.ts +120 -0
  256. package/examples/kitchen-sink/test/issues/issues.test.ts +105 -75
  257. package/examples/kitchen-sink/test/request-policy.test.ts +130 -33
  258. package/examples/kitchen-sink/test/routes.test.ts +102 -57
  259. package/examples/kitchen-sink/test/support/as.ts +9 -0
  260. package/examples/kitchen-sink/test/support/scripted.ts +6 -5
  261. package/examples/kitchen-sink/test/support/services.ts +17 -0
  262. package/examples/live-rpc/README.md +33 -35
  263. package/examples/live-rpc/src/Rpc.ts +9 -5
  264. package/examples/live-rpc/src/browser.ts +39 -11
  265. package/examples/live-rpc/src/chat/Chat.ts +30 -16
  266. package/examples/live-rpc/src/chat/authorize.ts +2 -1
  267. package/examples/live-rpc/src/chat/events.ts +74 -0
  268. package/examples/live-rpc/src/chat/handlers.ts +13 -5
  269. package/examples/live-rpc/src/chat/room.ts +9 -5
  270. package/examples/live-rpc/src/chat/store.ts +51 -14
  271. package/examples/live-rpc/src/client.ts +22 -5
  272. package/examples/live-rpc/src/identity/Identity.ts +3 -3
  273. package/examples/live-rpc/src/identity/verifier.ts +8 -4
  274. package/examples/live-rpc/src/page.ts +58 -12
  275. package/examples/live-rpc/src/routes.ts +14 -10
  276. package/examples/live-rpc/src/server.ts +15 -5
  277. package/examples/live-rpc/test/browser.test.ts +127 -50
  278. package/examples/live-rpc/test/chat/chat.test.ts +215 -23
  279. package/examples/live-rpc/test/routes.test.ts +130 -46
  280. package/examples/live-rpc/test/server.test.ts +94 -33
  281. package/examples/mcp-tools/README.md +24 -38
  282. package/examples/mcp-tools/src/platform/fake-farm.ts +63 -24
  283. package/examples/mcp-tools/src/platform/print-farm.ts +59 -29
  284. package/examples/mcp-tools/src/printers/Printers.ts +18 -7
  285. package/examples/mcp-tools/src/printers/farm.ts +15 -5
  286. package/examples/mcp-tools/src/printers/handlers.ts +12 -6
  287. package/examples/mcp-tools/src/routes.ts +8 -6
  288. package/examples/mcp-tools/src/server.ts +12 -5
  289. package/examples/mcp-tools/src/stdio.ts +3 -2
  290. package/examples/mcp-tools/test/printers/printers.test.ts +132 -89
  291. package/examples/mcp-tools/test/routes.test.ts +140 -81
  292. package/examples/notes-service/README.md +30 -45
  293. package/examples/notes-service/src/Api.ts +1 -0
  294. package/examples/notes-service/src/browser.ts +15 -14
  295. package/examples/notes-service/src/cli.ts +8 -3
  296. package/examples/notes-service/src/commands.ts +46 -20
  297. package/examples/notes-service/src/identity/Identity.ts +2 -2
  298. package/examples/notes-service/src/identity/authorize.ts +6 -2
  299. package/examples/notes-service/src/identity/handlers.ts +3 -2
  300. package/examples/notes-service/src/identity/users.ts +2 -1
  301. package/examples/notes-service/src/identity/verifier.ts +12 -6
  302. package/examples/notes-service/src/notes/Notes.ts +46 -22
  303. package/examples/notes-service/src/notes/handlers.ts +18 -9
  304. package/examples/notes-service/src/notes/store.ts +70 -43
  305. package/examples/notes-service/src/routes.ts +7 -5
  306. package/examples/notes-service/src/server.ts +12 -5
  307. package/examples/notes-service/src/status/Status.ts +1 -1
  308. package/examples/notes-service/src/status/handlers.ts +5 -3
  309. package/examples/notes-service/src/stdio.ts +7 -3
  310. package/examples/notes-service/src/worker.ts +6 -3
  311. package/examples/notes-service/test/commands.test.ts +164 -91
  312. package/examples/notes-service/test/notes/as.ts +3 -1
  313. package/examples/notes-service/test/notes/idempotency.test.ts +97 -67
  314. package/examples/notes-service/test/notes/in-process.test.ts +26 -15
  315. package/examples/notes-service/test/notes/notes.test.ts +49 -31
  316. package/examples/notes-service/test/routes.test.ts +106 -71
  317. package/examples/notes-service/test/worker.test.ts +21 -13
  318. package/examples/quickstart/README.md +2 -6
  319. package/examples/quickstart/src/Api.ts +1 -0
  320. package/examples/quickstart/src/client.ts +6 -3
  321. package/examples/quickstart/src/greeting/Greeting.ts +1 -1
  322. package/examples/quickstart/src/greeting/handlers.ts +5 -2
  323. package/examples/quickstart/src/routes.ts +3 -2
  324. package/examples/quickstart/src/server.ts +6 -2
  325. package/examples/quickstart/test/routes.test.ts +3 -2
  326. package/package.json +19 -17
@@ -1,15 +1,24 @@
1
- import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
1
+ import {
2
+ NodeHttpClient,
3
+ NodeRuntime,
4
+ NodeServices,
5
+ } from "@effect/platform-node";
6
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
7
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
2
8
  import { Config, Effect, flow, Option } from "effect";
3
9
  import { Command } from "effect/cli";
4
10
  import { HttpClient, HttpClientRequest } from "effect/http";
5
- import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
6
- import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
11
+
7
12
  import { Http } from "./Api.js";
8
13
 
14
+ const unchanged = (request: HttpClientRequest.HttpClientRequest) => request;
15
+
9
16
  // Entrypoint: `node --import tsx examples/kitchen-sink/src/cli.ts list-issues`.
10
17
  // The members' CLI: a command per action of the binding, a flag per input field, each calling
11
18
  // the server over HTTP. It imports the binding alone and runs nothing locally: the server
12
- // authenticates and authorizes, as for any client.
19
+ // authenticates and authorizes, as for any client. The binding's event adds `tracker watch
20
+ // issue.changed --status open`, printing each change of the member's workspace as a line of
21
+ // JSON as it happens; `--cursor` resumes from the last one's.
13
22
  const tracker = ActionCli.remote(Http, {
14
23
  name: "tracker",
15
24
  // Each command's options, keyed by its action's name and typed by that action.
@@ -18,10 +27,13 @@ const tracker = ActionCli.remote(Http, {
18
27
  // the JSON back). Its help says so: the action's description, which HTTP and MCP keep, says
19
28
  // what the JSON holds.
20
29
  listIssues: {
21
- description: "List your workspace's issues, newest first, one line each: id, status, title.",
30
+ description:
31
+ "List your workspace's issues, newest first, one line each: id, status, title.",
22
32
  aliases: { limit: "n", status: "s" },
23
33
  render: ({ issues }) =>
24
- issues.map(({ id, status, title }) => `${id} ${status} ${title}`).join("\n"),
34
+ issues
35
+ .map(({ id, status, title }) => `${id} ${status} ${title}`)
36
+ .join("\n"),
25
37
  },
26
38
  // `tracker get-issue ACME-1`: the id is an argument, not a flag.
27
39
  getIssue: {
@@ -37,6 +49,12 @@ const tracker = ActionCli.remote(Http, {
37
49
  createIssue: { stdin: "body" },
38
50
  commentOnIssue: { name: "comment", positional: ["id"], stdin: "text" },
39
51
  closeIssue: { name: "close", positional: ["id"] },
52
+ // An action answering a stream: `render` takes each issue, printed as it arrives, one line
53
+ // each; `--json` prints each as a line of JSON instead, so `tracker export-issues --json >
54
+ // issues.jsonl` writes JSON Lines.
55
+ exportIssues: {
56
+ render: ({ id, status, title }) => `${id} ${status} ${title}`,
57
+ },
40
58
  // `render` receives the decoded success, the image's bytes a `Uint8Array`.
41
59
  getAttachment: {
42
60
  positional: ["id"],
@@ -49,14 +67,12 @@ const tracker = ActionCli.remote(Http, {
49
67
  // a workspace its 403.
50
68
  client: Effect.gen(function* () {
51
69
  const baseUrl = yield* Config.String("TRACKER_URL").pipe(
52
- Config.withDefault("http://127.0.0.1:3000"),
70
+ Config.withDefault("http://127.0.0.1:3000")
53
71
  );
54
72
 
55
73
  const token = yield* Config.option(Config.Redacted("TRACKER_TOKEN"));
56
74
  const workspace = yield* Config.option(Config.String("TRACKER_WORKSPACE"));
57
75
 
58
- const unchanged = (request: HttpClientRequest.HttpClientRequest) => request;
59
-
60
76
  return yield* ActionHttp.client(Http, {
61
77
  baseUrl,
62
78
  transformClient: HttpClient.mapRequest(
@@ -68,8 +84,8 @@ const tracker = ActionCli.remote(Http, {
68
84
  Option.match(workspace, {
69
85
  onNone: () => unchanged,
70
86
  onSome: (slug) => HttpClientRequest.setHeader("x-workspace", slug),
71
- }),
72
- ),
87
+ })
88
+ )
73
89
  ),
74
90
  });
75
91
  }),
@@ -78,5 +94,5 @@ const tracker = ActionCli.remote(Http, {
78
94
  Command.run(tracker, { version: "1.0.0" }).pipe(
79
95
  Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
80
96
  ActionCli.logToStderr,
81
- NodeRuntime.runMain,
97
+ NodeRuntime.runMain()
82
98
  );
@@ -5,7 +5,7 @@ import { Config } from "effect";
5
5
 
6
6
  /** Where callers reach the public port: the OAuth resource, and a Host the server answers to. */
7
7
  export const publicUrl = Config.String("PUBLIC_URL").pipe(
8
- Config.withDefault("http://localhost:3000"),
8
+ Config.withDefault("http://localhost:3000")
9
9
  );
10
10
 
11
11
  /**
@@ -1,7 +1,7 @@
1
- import { Context, Schema } from "effect";
2
- import { HttpApiSecurity } from "effect/http-api";
3
1
  import * as Action from "@gjermundgaraba/effect-actions/Action";
4
2
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
3
+ import { Context, Schema } from "effect";
4
+ import { HttpApiSecurity } from "effect/http-api";
5
5
 
6
6
  export const scopes = { read: "issues:read", write: "issues:write" } as const;
7
7
 
@@ -19,21 +19,27 @@ export interface Member {
19
19
  * provides it per request, and a local host (stdio, the assistant, a test) provides it itself.
20
20
  */
21
21
  export class CurrentMember extends Context.Service<CurrentMember, Member>()(
22
- "tracker/CurrentMember",
22
+ "tracker/CurrentMember"
23
23
  ) {}
24
24
 
25
25
  // What a verifier may fail with besides a refusal: the issuer it checks tokens with is down.
26
26
  // It is not the caller's fault, so it is no 401: signing in again cannot help.
27
- export class IssuerUnavailable extends Schema.TaggedError<IssuerUnavailable>()(
27
+ export class IssuerUnavailable extends Schema.TaggedError<
28
+ IssuerUnavailable,
29
+ { readonly brand: unique symbol }
30
+ >()(
28
31
  "IssuerUnavailable",
29
32
  { retryAfterSeconds: Schema.Finite },
30
- { httpApiStatus: 503 },
33
+ { httpApiStatus: 503 }
31
34
  ) {}
32
35
 
33
36
  // How an API client, the CLI and an MCP client prove they are CurrentMember: a bearer token,
34
- // the default scheme.
37
+ // the default scheme. `principal` names one subscriber, for what MCP and HTTP hold between its
38
+ // requests, such as its webhook subscriptions: a member of two workspaces is two subscribers,
39
+ // so it names the workspace too, encoded so no two pairs spell one principal.
35
40
  export const Login = Authentication.make("tracker.Login", CurrentMember, {
36
41
  error: IssuerUnavailable,
42
+ principal: (member) => JSON.stringify([member.workspace, member.id]),
37
43
  });
38
44
 
39
45
  // How the web app proves the same identity: the session cookie its sign-in page set. It is
@@ -46,16 +52,24 @@ export const Session = Authentication.make("tracker.Session", CurrentMember, {
46
52
  // What the API's request quota answers with. It is the bindings' error, not an action's:
47
53
  // middleware sends it on any action's route, before its input is decoded. Only HTTP and RPC
48
54
  // have that middleware.
49
- export class QuotaExceeded extends Schema.TaggedError<QuotaExceeded>()(
55
+ export class QuotaExceeded extends Schema.TaggedError<
56
+ QuotaExceeded,
57
+ { readonly brand: unique symbol }
58
+ >()(
50
59
  "QuotaExceeded",
51
60
  { retryAfterSeconds: Schema.Finite },
52
- { httpApiStatus: 429 },
61
+ { httpApiStatus: 429 }
53
62
  ) {}
54
63
 
55
64
  // An action without input: its client method takes no argument, its command no flag.
56
65
  export const WhoAmI = Action.make("whoAmI", {
57
- description: "Show who the credential identifies, in which workspace, with which scopes.",
58
- success: { id: Schema.String, workspace: Schema.String, scopes: Schema.Array(Scope) },
66
+ description:
67
+ "Show who the credential identifies, in which workspace, with which scopes.",
68
+ success: {
69
+ id: Schema.String,
70
+ workspace: Schema.String,
71
+ scopes: Schema.Array(Scope),
72
+ },
59
73
  readOnly: true,
60
74
  caller: CurrentMember,
61
75
  });
@@ -1,11 +1,14 @@
1
- import { Array, Effect, Option, type Redacted } from "effect";
2
1
  import * as Action from "@gjermundgaraba/effect-actions/Action";
3
2
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
3
+ import { Array, Effect, Option } from "effect";
4
+ import type { Redacted } from "effect";
5
+
4
6
  import { publicUrl } from "../config.js";
5
7
  import { WorkspaceRegistry } from "../workspaces/registry.js";
6
8
  import { Tenant } from "../workspaces/tenant.js";
7
9
  import { Directory } from "./directory.js";
8
- import { Login, type Member, scopes, Session } from "./Identity.js";
10
+ import { Login, scopes, Session } from "./Identity.js";
11
+ import type { Member } from "./Identity.js";
9
12
 
10
13
  // What both verifiers check once a credential has named a member: an operator may have
11
14
  // suspended the workspace. Checked here, it holds on every remote surface.
@@ -15,12 +18,16 @@ const inService = Effect.gen(function* () {
15
18
  return Effect.fn("inService")(function* (member: Member) {
16
19
  const suspended = yield* registry.get(member.workspace).pipe(
17
20
  Effect.map((workspace) => workspace.suspended),
18
- Effect.orElseSucceed(() => true),
21
+ Effect.orElseSucceed(() => true)
19
22
  );
20
23
 
21
24
  // No scopes named: signing in again grants nothing, so this is a plain 403, never a
22
25
  // challenge sending an OAuth client back to its authorization server.
23
- if (suspended) return yield* new Action.Forbidden({ message: "This workspace is suspended." });
26
+ if (suspended) {
27
+ return yield* new Action.Forbidden({
28
+ message: "This workspace is suspended.",
29
+ });
30
+ }
24
31
 
25
32
  return member;
26
33
  });
@@ -38,7 +45,7 @@ const protectedResource = Effect.map(
38
45
  scopesSupported: [scopes.read, scopes.write],
39
46
  scopesRequired: [scopes.read],
40
47
  resourceName: "Tracker",
41
- }) satisfies Authentication.ProtectedResource,
48
+ }) satisfies Authentication.ProtectedResource
42
49
  );
43
50
 
44
51
  // Server only: `Login`'s provider. The Effect is a builder, as `Action.implement` takes one:
@@ -50,24 +57,25 @@ export const authenticateBearer = Authentication.layer(
50
57
  const directory = yield* Directory;
51
58
  const admit = yield* inService;
52
59
 
53
- return (token: Redacted.Redacted<string>) =>
60
+ return (token: Redacted.Redacted) =>
54
61
  Effect.gen(function* () {
55
62
  const memberships = yield* directory.ofToken(token);
56
63
 
57
64
  const member = Option.flatMap(yield* Tenant, (slug) =>
58
- Array.findFirst(memberships, ({ workspace }) => workspace === slug),
65
+ Array.findFirst(memberships, ({ workspace }) => workspace === slug)
59
66
  );
60
67
 
61
68
  if (Option.isNone(member)) {
62
69
  return yield* new Action.Forbidden({
63
- message: "Name a workspace you belong to in the X-Workspace header.",
70
+ message:
71
+ "Name a workspace you belong to in the X-Workspace header.",
64
72
  });
65
73
  }
66
74
 
67
75
  return yield* admit(member.value);
68
76
  });
69
77
  }),
70
- { protectedResource },
78
+ { protectedResource }
71
79
  );
72
80
 
73
81
  // `Session`'s provider: the same identity from another credential. Its verifier receives what
@@ -79,7 +87,7 @@ export const authenticateSession = Authentication.layer(
79
87
  const directory = yield* Directory;
80
88
  const admit = yield* inService;
81
89
 
82
- return (session: Redacted.Redacted<string>) =>
90
+ return (session: Redacted.Redacted) =>
83
91
  Effect.flatMap(directory.ofSession(session), admit);
84
- }),
92
+ })
85
93
  );
@@ -1,5 +1,7 @@
1
- import { Effect } from "effect";
2
1
  import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
3
+ import { Effect } from "effect";
4
+
3
5
  import { CurrentMember, scopes } from "./Identity.js";
4
6
 
5
7
  // The members' rule, read from each contract: a read needs the read scope, anything else the
@@ -7,11 +9,19 @@ import { CurrentMember, scopes } from "./Identity.js";
7
9
  // them runs it before the handler: HTTP, MCP, RPC, stdio, the assistant's tools and a test alike.
8
10
  // For a bearer token, naming the missing scope turns the 403 into the challenge an OAuth client
9
11
  // re-authorizes on.
10
- export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
11
- const scope = action.readOnly ? scopes.read : scopes.write;
12
+ export const authorize = Effect.fn("authorize")(function* (
13
+ contract: Action.Any | Event.Any
14
+ ) {
15
+ // A subscription to an event reads, as a `readOnly` action does.
16
+ const scope =
17
+ Event.isEvent(contract) || contract.readOnly ? scopes.read : scopes.write;
18
+
12
19
  const member = yield* CurrentMember;
13
20
 
14
21
  if (!member.scopes.includes(scope)) {
15
- return yield* new Action.Forbidden({ message: `Requires ${scope}.`, scopes: [scope] });
22
+ return yield* new Action.Forbidden({
23
+ message: `Requires ${scope}.`,
24
+ scopes: [scope],
25
+ });
16
26
  }
17
27
  });
@@ -1,5 +1,6 @@
1
- import { Context, Effect, Layer, Redacted } from "effect";
2
1
  import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import { Context, Effect, Layer, Redacted } from "effect";
3
+
3
4
  import type { IssuerUnavailable, Member } from "./Identity.js";
4
5
  import { members } from "./members.js";
5
6
 
@@ -12,11 +13,14 @@ export class Directory extends Context.Service<
12
13
  {
13
14
  /** Every membership of the account an access token was issued to. */
14
15
  readonly ofToken: (
15
- token: Redacted.Redacted<string>,
16
- ) => Effect.Effect<ReadonlyArray<Member>, Action.Unauthenticated | IssuerUnavailable>;
16
+ token: Redacted.Redacted
17
+ ) => Effect.Effect<
18
+ readonly Member[],
19
+ Action.Unauthenticated | IssuerUnavailable
20
+ >;
17
21
  /** The member a web session was opened for: a session belongs to one workspace. */
18
22
  readonly ofSession: (
19
- session: Redacted.Redacted<string>,
23
+ session: Redacted.Redacted
20
24
  ) => Effect.Effect<Member, Action.Unauthenticated>;
21
25
  }
22
26
  >()("tracker/Directory") {
@@ -25,7 +29,7 @@ export class Directory extends Context.Service<
25
29
  // its audience included, failing with `IssuerUnavailable` when it cannot reach its keys, and
26
30
  // reads sessions from their store.
27
31
  static readonly layerDemo = Layer.sync(Directory, () => {
28
- const accounts = new Map<string, ReadonlyArray<Member>>([
32
+ const accounts = new Map<string, readonly Member[]>([
29
33
  ["ada", [members.ada, members.adaAtGlobex]],
30
34
  ["linus", [members.linus]],
31
35
  ["kay", [members.kay]],
@@ -41,11 +45,16 @@ export class Directory extends Context.Service<
41
45
  return Directory.of({
42
46
  ofToken: (token) =>
43
47
  Effect.fromNullishOr(accounts.get(Redacted.value(token))).pipe(
44
- Effect.mapError(() => new Action.Unauthenticated({ message: "Unknown demo token." })),
48
+ Effect.mapError(
49
+ () => new Action.Unauthenticated({ message: "Unknown demo token." })
50
+ )
45
51
  ),
46
52
  ofSession: (session) =>
47
53
  Effect.fromNullishOr(sessions.get(Redacted.value(session))).pipe(
48
- Effect.mapError(() => new Action.Unauthenticated({ message: "Unknown demo session." })),
54
+ Effect.mapError(
55
+ () =>
56
+ new Action.Unauthenticated({ message: "Unknown demo session." })
57
+ )
49
58
  ),
50
59
  });
51
60
  });
@@ -1,4 +1,5 @@
1
1
  import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+
2
3
  import { CurrentMember, WhoAmI } from "./Identity.js";
3
4
 
4
5
  // `Action.allowAll`: verification is the whole policy here. Any authenticated member may ask
@@ -1,4 +1,5 @@
1
- import { type Member, scopes } from "./Identity.js";
1
+ import { scopes } from "./Identity.js";
2
+ import type { Member } from "./Identity.js";
2
3
 
3
4
  // DEMO ONLY: the people of the demo, each a member of one workspace. A real application has no
4
5
  // such table: its directory reads a member from a signed token or a stored session. A local
@@ -9,5 +10,9 @@ export const members = {
9
10
  linus: { id: "linus", workspace: "acme", scopes: [scopes.read] },
10
11
  // Invited to acme, granted nothing yet.
11
12
  kay: { id: "kay", workspace: "acme", scopes: [] },
12
- grace: { id: "grace", workspace: "globex", scopes: [scopes.read, scopes.write] },
13
+ grace: {
14
+ id: "grace",
15
+ workspace: "globex",
16
+ scopes: [scopes.read, scopes.write],
17
+ },
13
18
  } as const satisfies Readonly<Record<string, Member>>;
@@ -2,6 +2,7 @@ import { Duration, Effect, Layer } from "effect";
2
2
  import { HttpApiMiddleware } from "effect/http-api";
3
3
  import { RateLimiter } from "effect/persistence";
4
4
  import { RpcMiddleware } from "effect/rpc";
5
+
5
6
  import { CurrentMember, QuotaExceeded } from "./Identity.js";
6
7
 
7
8
  // The API's request quota: 60 requests a minute per member. It is a surface's own middleware,
@@ -10,24 +11,22 @@ import { CurrentMember, QuotaExceeded } from "./Identity.js";
10
11
  // caller. HTTP and RPC each have their native middleware; an MCP endpoint takes none.
11
12
 
12
13
  /** Listed in `ActionHttp.layer`'s `middleware`. It fails only with an error the binding declares. */
13
- export class HttpQuota extends HttpApiMiddleware.Service<HttpQuota, { requires: CurrentMember }>()(
14
- "tracker/HttpQuota",
15
- { error: QuotaExceeded },
16
- ) {}
14
+ export class HttpQuota extends HttpApiMiddleware.Service<
15
+ HttpQuota,
16
+ { requires: CurrentMember }
17
+ >()("tracker/HttpQuota", { error: QuotaExceeded }) {}
17
18
 
18
19
  /** Listed in `ActionRpc.layer`'s `middleware`: the same quota, counted per message. */
19
- export class RpcQuota extends RpcMiddleware.Service<RpcQuota, { requires: CurrentMember }>()(
20
- "tracker/RpcQuota",
21
- { error: QuotaExceeded },
22
- ) {}
20
+ export class RpcQuota extends RpcMiddleware.Service<
21
+ RpcQuota,
22
+ { requires: CurrentMember }
23
+ >()("tracker/RpcQuota", { error: QuotaExceeded }) {}
23
24
 
24
25
  // What both middleware do before a call. Each middleware's layer builds this for itself, so it
25
26
  // runs twice; the count is one all the same, because both yield the one `RateLimiter` the
26
27
  // entrypoint provides and spend under the same key.
27
- const spend = Effect.gen(function* () {
28
- const limiter = yield* RateLimiter.RateLimiter;
29
-
30
- return Effect.gen(function* () {
28
+ const spend = Effect.map(RateLimiter.RateLimiter, (limiter) =>
29
+ Effect.gen(function* () {
31
30
  const member = yield* CurrentMember;
32
31
 
33
32
  yield* limiter
@@ -42,21 +41,25 @@ const spend = Effect.gen(function* () {
42
41
  "RateLimiterError",
43
42
  "RateLimitExceeded",
44
43
  ({ retryAfter }) =>
45
- Effect.fail(new QuotaExceeded({ retryAfterSeconds: Duration.toSeconds(retryAfter) })),
44
+ Effect.fail(
45
+ new QuotaExceeded({
46
+ retryAfterSeconds: Duration.toSeconds(retryAfter),
47
+ })
48
+ ),
46
49
  // Any other reason is the limiter's store failing: a defect, never the caller's.
47
- Effect.die,
48
- ),
50
+ Effect.die
51
+ )
49
52
  );
50
- });
51
- });
53
+ })
54
+ );
52
55
 
53
56
  export const quota = Layer.merge(
54
57
  Layer.effect(
55
58
  HttpQuota,
56
- Effect.map(spend, (spent) => (route) => Effect.andThen(spent, route)),
59
+ Effect.map(spend, (spent) => (route) => Effect.andThen(spent, route))
57
60
  ),
58
61
  Layer.effect(
59
62
  RpcQuota,
60
- Effect.map(spend, (spent) => (rpc) => Effect.andThen(spent, rpc)),
61
- ),
63
+ Effect.map(spend, (spent) => (rpc) => Effect.andThen(spent, rpc))
64
+ )
62
65
  );
@@ -1,10 +1,15 @@
1
- import { Effect, Schema } from "effect";
2
1
  import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
3
+ import { Effect, Schema } from "effect";
4
+
3
5
  import { CurrentMember } from "../identity/Identity.js";
4
6
 
5
7
  export const Status = Schema.Literals(["open", "closed"]);
6
8
 
7
- export const Comment = Schema.Struct({ author: Schema.String, text: Schema.String });
9
+ export const Comment = Schema.Struct({
10
+ author: Schema.String,
11
+ text: Schema.String,
12
+ });
8
13
 
9
14
  export const Issue = Schema.Struct({
10
15
  id: Schema.String,
@@ -19,16 +24,18 @@ export type Issue = typeof Issue.Type;
19
24
 
20
25
  // A declared error is part of the contract: every client decodes it as this class, and its
21
26
  // status is what HTTP answers with. Without `httpApiStatus` it would be a 422.
22
- export class IssueNotFound extends Schema.TaggedError<IssueNotFound>()(
23
- "IssueNotFound",
24
- { id: Schema.String },
25
- { httpApiStatus: 404 },
26
- ) {}
27
+ export class IssueNotFound extends Schema.TaggedError<
28
+ IssueNotFound,
29
+ { readonly brand: unique symbol }
30
+ >()("IssueNotFound", { id: Schema.String }, { httpApiStatus: 404 }) {}
27
31
 
28
- export class RateLimited extends Schema.TaggedError<RateLimited>()(
32
+ export class RateLimited extends Schema.TaggedError<
33
+ RateLimited,
34
+ { readonly brand: unique symbol }
35
+ >()(
29
36
  "RateLimited",
30
37
  { retryAfterSeconds: Schema.Finite },
31
- { httpApiStatus: 429 },
38
+ { httpApiStatus: 429 }
32
39
  ) {}
33
40
 
34
41
  // A shared error list: what any write to an existing issue may fail with. Each such action
@@ -51,20 +58,23 @@ const change = {
51
58
  const Text = Schema.String.check(Schema.isMinLength(1));
52
59
 
53
60
  export const ListIssues = Action.make("listIssues", {
54
- description: "List your workspace's issues, newest first, without bodies or comments.",
61
+ description:
62
+ "List your workspace's issues, newest first, without bodies or comments.",
55
63
  input: {
56
64
  // Optional: left out, issues of every status are listed.
57
65
  status: Schema.optional(Status),
58
66
  // Defaulted: left out, the handler receives 20. A field description is also the help text
59
67
  // of its CLI flag and the description in the tool's schema.
60
- limit: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 })).pipe(
68
+ limit: Schema.Int.check(
69
+ Schema.isBetween({ minimum: 1, maximum: 100 })
70
+ ).pipe(
61
71
  Schema.annotate({ description: "How many issues to list, at most 100." }),
62
- Schema.withDecodingDefaultKey(Effect.succeed(20)),
72
+ Schema.withDecodingDefaultKey(Effect.succeed(20))
63
73
  ),
64
74
  },
65
75
  success: {
66
76
  issues: Schema.Array(
67
- Schema.Struct({ id: Schema.String, title: Schema.String, status: Status }),
77
+ Schema.Struct({ id: Schema.String, title: Schema.String, status: Status })
68
78
  ),
69
79
  },
70
80
  readOnly: true,
@@ -82,7 +92,11 @@ export const GetIssue = Action.make("getIssue", {
82
92
 
83
93
  export const CreateIssue = Action.make("createIssue", {
84
94
  description: "File an issue in your workspace.",
85
- input: { title: Text, body: Schema.String, assignee: Schema.optional(Schema.String) },
95
+ input: {
96
+ title: Text,
97
+ body: Schema.String,
98
+ assignee: Schema.optional(Schema.String),
99
+ },
86
100
  success: Issue,
87
101
  error: RateLimited,
88
102
  readOnly: false,
@@ -108,4 +122,66 @@ export const CloseIssue = Action.make("closeIssue", {
108
122
  mcp: { ...change.mcp, idempotentHint: true },
109
123
  });
110
124
 
111
- export const Issues = [ListIssues, GetIssue, CreateIssue, CommentOnIssue, CloseIssue] as const;
125
+ export const Issues = [
126
+ ListIssues,
127
+ GetIssue,
128
+ CreateIssue,
129
+ CommentOnIssue,
130
+ CloseIssue,
131
+ ] as const;
132
+
133
+ // An action answering a stream: its handler returns a `Stream` of issues, and every client's
134
+ // call returns one, each issue read as the server sends it. It reports how far it is, too.
135
+ // `Issues` leaves it out, so MCP's `Tools` and the assistant's tools do: a tool answers one
136
+ // result, so it would collect the whole export before answering, every issue at once in a
137
+ // model's context. HTTP, RPC and the CLI serve it.
138
+ export const ExportIssues = Action.make("exportIssues", {
139
+ description:
140
+ "Stream every issue of your workspace, oldest first, with bodies and comments.",
141
+ input: {
142
+ status: Schema.optional(Status).annotate({
143
+ description: "Only issues of this status.",
144
+ }),
145
+ },
146
+ success: Action.Stream(Issue),
147
+ progress: {
148
+ exported: Schema.Finite.annotate({
149
+ description: "How many issues were sent.",
150
+ }),
151
+ total: Schema.Finite.annotate({
152
+ description: "How many the export holds.",
153
+ }),
154
+ },
155
+ // An export is costly, so it is limited too: refused before its first issue, a 429.
156
+ error: RateLimited,
157
+ readOnly: true,
158
+ caller: CurrentMember,
159
+ });
160
+
161
+ /** What changed about an issue, as `issue.changed` carries it. */
162
+ export const IssueChange = Schema.Struct({
163
+ workspace: Schema.String,
164
+ id: Schema.String,
165
+ title: Schema.String,
166
+ status: Status,
167
+ change: Schema.Literals(["filed", "commented", "closed"]),
168
+ // The comment a change added; a subscriber asking `comments: false` receives none.
169
+ comment: Schema.optional(Comment),
170
+ });
171
+
172
+ // An event type: what a subscription takes, what each occurrence carries, and who may
173
+ // subscribe. `input` both filters the occurrences, by `status`, and shapes them, by `comments`.
174
+ export const IssueChanged = Event.make("issue.changed", {
175
+ description: "An issue in your workspace was filed, commented on or closed.",
176
+ input: {
177
+ status: Schema.optional(Status).annotate({
178
+ description: "Only issues now of this status.",
179
+ }),
180
+ comments: Schema.optional(Schema.Boolean).annotate({
181
+ description:
182
+ "Whether a change carries the comment it added; omitted, it does.",
183
+ }),
184
+ },
185
+ payload: IssueChange,
186
+ caller: CurrentMember,
187
+ });
@@ -1,5 +1,6 @@
1
- import { LanguageModel } from "effect/ai";
2
1
  import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
2
+ import { LanguageModel } from "effect/ai";
3
+
3
4
  import { issues } from "./handlers.js";
4
5
  import { CloseIssue, CommentOnIssue, GetIssue, ListIssues } from "./Issues.js";
5
6
 
@@ -20,4 +21,5 @@ export const { toolkit, layer } = ActionToolkit.make(issues, {
20
21
 
21
22
  // One turn of the assistant. The caller provides the model, and the member it acts for around
22
23
  // the whole turn: `authorize` and the handlers read that one caller.
23
- export const triage = (prompt: string) => LanguageModel.generateText({ prompt, toolkit });
24
+ export const triage = (prompt: string) =>
25
+ LanguageModel.generateText({ prompt, toolkit });
@@ -0,0 +1,46 @@
1
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
2
+ import { Effect, Stream, Struct } from "effect";
3
+
4
+ import { authorize } from "../identity/authorize.js";
5
+ import { CurrentMember } from "../identity/Identity.js";
6
+ import { IssueChanged } from "./Issues.js";
7
+ import { IssueStore } from "./store.js";
8
+
9
+ // A log source fed by a stream: the store pushes every change, of every workspace, and the
10
+ // library consumes the stream once, from startup, keeping what it produced lately for
11
+ // subscribers resuming from a cursor. `key`, `match` and `transform` run per subscriber, as the
12
+ // subscriber.
13
+ export const issueEvents = Event.implement(
14
+ IssueChanged,
15
+ Effect.gen(function* () {
16
+ const store = yield* IssueStore;
17
+
18
+ return {
19
+ log: {
20
+ // No `eventId`: the library gives each change one, kept on every replay. An id of the
21
+ // store's would have to stay unique across restarts, as a subscriber drops a repeated
22
+ // one. Each change is keyed by its workspace, which reaches that workspace's
23
+ // subscribers alone.
24
+ emit: Stream.map(store.changes, (data) => ({
25
+ data,
26
+ key: data.workspace,
27
+ })),
28
+ // One log holds every workspace's changes: a subscriber resuming within an hour, and
29
+ // within the last thousand changes of all workspaces, misses nothing; one resuming
30
+ // later receives a gap first, then what is kept. A busy workspace shortens a quiet
31
+ // one's hour.
32
+ maxAge: "1 hour",
33
+ maxEvents: 1000,
34
+ },
35
+ // Tenant isolation, as the store's methods hold it for the actions: a member follows
36
+ // their own workspace's key. It is never `undefined`, which would follow every key.
37
+ key: () => Effect.map(CurrentMember, (member) => member.workspace),
38
+ match: ({ status }, change) =>
39
+ status === undefined || change.status === status,
40
+ transform: ({ comments }, change) =>
41
+ comments === false ? Struct.omit(change, ["comment"]) : change,
42
+ };
43
+ }),
44
+ // The members' rule, as for the actions: a subscription needs the read scope.
45
+ { authorize }
46
+ );