@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.
- package/README.md +19 -41
- package/dist/Action.d.mts +140 -48
- package/dist/Action.mjs +88 -44
- package/dist/ActionCli.d.mts +70 -35
- package/dist/ActionCli.mjs +53 -34
- package/dist/ActionHttp.d.mts +70 -38
- package/dist/ActionHttp.mjs +76 -25
- package/dist/ActionMcp.d.mts +56 -24
- package/dist/ActionMcp.mjs +104 -61
- package/dist/ActionRpc.d.mts +222 -57
- package/dist/ActionRpc.mjs +301 -49
- package/dist/ActionToolkit.d.mts +22 -7
- package/dist/ActionToolkit.mjs +37 -14
- package/dist/Authentication.d.mts +42 -12
- package/dist/Authentication.mjs +38 -32
- package/dist/Event.d.mts +370 -0
- package/dist/Event.mjs +103 -0
- package/dist/Testing.d.mts +1 -3
- package/dist/Testing.mjs +2 -3
- package/dist/Webhook.d.mts +146 -0
- package/dist/Webhook.mjs +72 -0
- package/dist/authentication/provider.d.mts +35 -12
- package/dist/authentication/provider.mjs +2 -3
- package/dist/authentication/refusal.mjs +3 -4
- package/dist/cli/commands.d.mts +9 -7
- package/dist/cli/commands.mjs +85 -35
- package/dist/contract/ast.mjs +5 -3
- package/dist/contract/authorizer.d.mts +8 -0
- package/dist/contract/authorizer.mjs +20 -0
- package/dist/contract/call.d.mts +9 -8
- package/dist/contract/call.mjs +49 -8
- package/dist/contract/callback.d.mts +7 -0
- package/dist/contract/callback.mjs +27 -0
- package/dist/contract/caller.d.mts +12 -3
- package/dist/contract/caller.mjs +23 -5
- package/dist/contract/canonical.mjs +10 -0
- package/dist/contract/codec.d.mts +9 -0
- package/dist/contract/codec.mjs +9 -0
- package/dist/contract/codes.mjs +81 -0
- package/dist/contract/declared.d.mts +0 -2
- package/dist/contract/declared.mjs +26 -11
- package/dist/contract/digest.mjs +6 -0
- package/dist/contract/errors.d.mts +9 -5
- package/dist/contract/errors.mjs +2 -3
- package/dist/contract/feed.d.mts +127 -0
- package/dist/contract/feed.mjs +249 -0
- package/dist/contract/hints.mjs +1 -2
- package/dist/contract/hub.d.mts +94 -0
- package/dist/contract/hub.mjs +133 -0
- package/dist/contract/idempotency.d.mts +47 -0
- package/dist/contract/idempotency.mjs +99 -0
- package/dist/contract/implementation.d.mts +73 -51
- package/dist/contract/implementation.mjs +94 -60
- package/dist/contract/input-check.d.mts +15 -0
- package/dist/contract/input-check.mjs +16 -0
- package/dist/contract/lease.mjs +28 -0
- package/dist/contract/listed.d.mts +4 -0
- package/dist/contract/listed.mjs +22 -0
- package/dist/contract/log.d.mts +41 -0
- package/dist/contract/log.mjs +144 -0
- package/dist/contract/media.d.mts +1 -3
- package/dist/contract/media.mjs +1 -2
- package/dist/contract/names.mjs +15 -6
- package/dist/contract/occurrence.d.mts +29 -0
- package/dist/contract/occurrence.mjs +45 -0
- package/dist/contract/pages.d.mts +13 -0
- package/dist/contract/pages.mjs +39 -0
- package/dist/contract/progress.d.mts +26 -0
- package/dist/contract/progress.mjs +72 -0
- package/dist/contract/push.d.mts +20 -0
- package/dist/contract/push.mjs +96 -0
- package/dist/contract/receiving.d.mts +29 -0
- package/dist/contract/receiving.mjs +42 -0
- package/dist/contract/recent.mjs +12 -0
- package/dist/contract/reported.mjs +16 -0
- package/dist/contract/serving.mjs +63 -0
- package/dist/contract/status.mjs +15 -7
- package/dist/contract/stream.d.mts +34 -0
- package/dist/contract/stream.mjs +31 -0
- package/dist/contract/subscribe.d.mts +45 -0
- package/dist/contract/subscribe.mjs +50 -0
- package/dist/contract/transient.mjs +28 -0
- package/dist/contract/watch.d.mts +18 -0
- package/dist/contract/watch.mjs +105 -0
- package/dist/contract/webhooks.d.mts +42 -0
- package/dist/contract/webhooks.mjs +53 -0
- package/dist/http/client.d.mts +37 -14
- package/dist/http/client.mjs +52 -14
- package/dist/http/events.d.mts +56 -0
- package/dist/http/events.mjs +102 -0
- package/dist/http/placed.d.mts +36 -0
- package/dist/http/placed.mjs +71 -0
- package/dist/http/schema-errors.mjs +2 -3
- package/dist/http/streamed.d.mts +21 -0
- package/dist/http/streamed.mjs +94 -0
- package/dist/http/watch.mjs +50 -0
- package/dist/mcp/bridge.mjs +283 -0
- package/dist/mcp/client.d.mts +30 -99
- package/dist/mcp/client.mjs +60 -134
- package/dist/mcp/events.mjs +142 -0
- package/dist/mcp/progress.mjs +34 -0
- package/dist/mcp/protocol.mjs +40 -15
- package/dist/mcp/reply.d.mts +79 -0
- package/dist/mcp/reply.mjs +99 -0
- package/dist/mcp/request.d.mts +40 -0
- package/dist/mcp/request.mjs +51 -0
- package/dist/mcp/watch.mjs +115 -0
- package/dist/rpc/watch.mjs +39 -0
- package/dist/stdio/console.mjs +14 -7
- package/dist/testing/memory.d.mts +1 -3
- package/dist/testing/memory.mjs +4 -5
- package/dist/webhook/address.mjs +47 -0
- package/dist/webhook/attempt.mjs +65 -0
- package/dist/webhook/receiver.mjs +67 -0
- package/dist/webhook/signing.mjs +28 -0
- package/dist/webhook/stored.mjs +57 -0
- package/dist/webhook/subscriptions.mjs +342 -0
- package/docs/Action-failures.md +2 -2
- package/docs/Action-streams.md +115 -0
- package/docs/Action.md +58 -124
- package/docs/ActionCli-failures.md +1 -2
- package/docs/ActionCli.md +83 -88
- package/docs/ActionHttp-failures.md +3 -3
- package/docs/ActionHttp.md +90 -70
- package/docs/ActionMcp-failures.md +2 -1
- package/docs/ActionMcp.md +120 -92
- package/docs/ActionRpc-events.md +126 -0
- package/docs/ActionRpc-failures.md +1 -2
- package/docs/ActionRpc.md +108 -79
- package/docs/ActionToolkit-failures.md +1 -2
- package/docs/ActionToolkit.md +33 -90
- package/docs/Authentication-advanced.md +57 -61
- package/docs/Authentication-failures.md +5 -4
- package/docs/Authentication.md +23 -58
- package/docs/Event-failures.md +15 -0
- package/docs/Event.md +281 -0
- package/docs/README.md +51 -82
- package/docs/Testing-failures.md +1 -5
- package/docs/Testing.md +126 -86
- package/docs/Webhook-failures.md +8 -0
- package/docs/Webhook.md +319 -0
- package/docs/setup-failures.md +2 -3
- package/docs/setup.md +32 -53
- package/examples/README.md +12 -37
- package/examples/assistant/README.md +21 -26
- package/examples/assistant/src/agent.ts +66 -24
- package/examples/assistant/src/cli.ts +7 -4
- package/examples/assistant/src/commands.ts +9 -4
- package/examples/assistant/src/identity/Identity.ts +5 -4
- package/examples/assistant/src/identity/authorize.ts +13 -5
- package/examples/assistant/src/identity/handlers.ts +3 -2
- package/examples/assistant/src/identity/permissions.ts +9 -3
- package/examples/assistant/src/platform/tasks-file.ts +15 -11
- package/examples/assistant/src/scripted.ts +7 -4
- package/examples/assistant/src/tasks/Tasks.ts +20 -6
- package/examples/assistant/src/tasks/handlers.ts +9 -5
- package/examples/assistant/src/tasks/store.ts +38 -11
- package/examples/assistant/src/toolkit.ts +11 -7
- package/examples/assistant/test/commands.test.ts +53 -27
- package/examples/assistant/test/identity/identity.test.ts +27 -11
- package/examples/assistant/test/platform/tasks-file.test.ts +39 -23
- package/examples/assistant/test/tasks/tasks.test.ts +62 -34
- package/examples/assistant/test/toolkit.test.ts +104 -75
- package/examples/build-events/README.md +83 -0
- package/examples/build-events/src/Api.ts +27 -0
- package/examples/build-events/src/Rpc.ts +26 -0
- package/examples/build-events/src/builds/Builds.ts +108 -0
- package/examples/build-events/src/builds/events.ts +54 -0
- package/examples/build-events/src/builds/handlers.ts +58 -0
- package/examples/build-events/src/builds/log.ts +30 -0
- package/examples/build-events/src/builds/status.ts +28 -0
- package/examples/build-events/src/builds/store.ts +130 -0
- package/examples/build-events/src/cli.ts +18 -0
- package/examples/build-events/src/commands.ts +32 -0
- package/examples/build-events/src/deploys/Deploys.ts +44 -0
- package/examples/build-events/src/deploys/approvers.ts +55 -0
- package/examples/build-events/src/deploys/events.ts +32 -0
- package/examples/build-events/src/deploys/handlers.ts +38 -0
- package/examples/build-events/src/identity/Identity.ts +29 -0
- package/examples/build-events/src/identity/members.ts +7 -0
- package/examples/build-events/src/identity/verifier.ts +22 -0
- package/examples/build-events/src/notifier/Notices.ts +26 -0
- package/examples/build-events/src/notifier/routes.ts +71 -0
- package/examples/build-events/src/notifier.ts +48 -0
- package/examples/build-events/src/routes.ts +71 -0
- package/examples/build-events/src/server.ts +62 -0
- package/examples/build-events/test/builds/events.test.ts +68 -0
- package/examples/build-events/test/builds/log.test.ts +71 -0
- package/examples/build-events/test/builds/progress.test.ts +57 -0
- package/examples/build-events/test/builds/status.test.ts +42 -0
- package/examples/build-events/test/commands.test.ts +103 -0
- package/examples/build-events/test/deploys/events.test.ts +52 -0
- package/examples/build-events/test/http.test.ts +95 -0
- package/examples/build-events/test/notifier.test.ts +80 -0
- package/examples/build-events/test/routes.test.ts +96 -0
- package/examples/build-events/test/rpc.test.ts +84 -0
- package/examples/build-events/test/support/as.ts +8 -0
- package/examples/build-events/test/support/memory.ts +43 -0
- package/examples/build-events/test/webhooks.test.ts +143 -0
- package/examples/kitchen-sink/README.md +178 -166
- package/examples/kitchen-sink/src/Actions.ts +11 -3
- package/examples/kitchen-sink/src/Api.ts +12 -6
- package/examples/kitchen-sink/src/Rpc.ts +7 -5
- package/examples/kitchen-sink/src/admin/Admin.ts +21 -10
- package/examples/kitchen-sink/src/admin/authenticate.ts +8 -4
- package/examples/kitchen-sink/src/admin/authorize.ts +13 -5
- package/examples/kitchen-sink/src/admin/grants.ts +5 -3
- package/examples/kitchen-sink/src/admin/handlers.ts +9 -5
- package/examples/kitchen-sink/src/agent.ts +14 -6
- package/examples/kitchen-sink/src/assistant/Assistant.ts +21 -15
- package/examples/kitchen-sink/src/assistant/conversations.ts +14 -9
- package/examples/kitchen-sink/src/assistant/demo-model.ts +2 -2
- package/examples/kitchen-sink/src/assistant/handlers.ts +42 -22
- package/examples/kitchen-sink/src/attachments/Attachments.ts +6 -6
- package/examples/kitchen-sink/src/attachments/download.ts +7 -6
- package/examples/kitchen-sink/src/attachments/handlers.ts +9 -4
- package/examples/kitchen-sink/src/attachments/store.ts +25 -18
- package/examples/kitchen-sink/src/browser.ts +10 -4
- package/examples/kitchen-sink/src/cli.ts +28 -12
- package/examples/kitchen-sink/src/config.ts +1 -1
- package/examples/kitchen-sink/src/identity/Identity.ts +24 -10
- package/examples/kitchen-sink/src/identity/authenticate.ts +19 -11
- package/examples/kitchen-sink/src/identity/authorize.ts +14 -4
- package/examples/kitchen-sink/src/identity/directory.ts +16 -7
- package/examples/kitchen-sink/src/identity/handlers.ts +1 -0
- package/examples/kitchen-sink/src/identity/members.ts +7 -2
- package/examples/kitchen-sink/src/identity/quota.ts +23 -20
- package/examples/kitchen-sink/src/issues/Issues.ts +91 -15
- package/examples/kitchen-sink/src/issues/assistant.ts +4 -2
- package/examples/kitchen-sink/src/issues/events.ts +46 -0
- package/examples/kitchen-sink/src/issues/export.ts +35 -0
- package/examples/kitchen-sink/src/issues/guide.ts +4 -3
- package/examples/kitchen-sink/src/issues/handlers.ts +20 -7
- package/examples/kitchen-sink/src/issues/store.ts +156 -67
- package/examples/kitchen-sink/src/issues/write-limit.ts +44 -18
- package/examples/kitchen-sink/src/memory.ts +11 -2
- package/examples/kitchen-sink/src/ops.ts +10 -5
- package/examples/kitchen-sink/src/request-policy.ts +25 -9
- package/examples/kitchen-sink/src/routes.ts +67 -27
- package/examples/kitchen-sink/src/sdk.ts +22 -12
- package/examples/kitchen-sink/src/seed.ts +15 -9
- package/examples/kitchen-sink/src/server.ts +24 -10
- package/examples/kitchen-sink/src/stdio.ts +9 -3
- package/examples/kitchen-sink/src/workspaces/Workspaces.ts +10 -7
- package/examples/kitchen-sink/src/workspaces/handlers.ts +3 -2
- package/examples/kitchen-sink/src/workspaces/registry.ts +16 -10
- package/examples/kitchen-sink/src/workspaces/tenant.ts +12 -5
- package/examples/kitchen-sink/test/admin/admin.test.ts +38 -23
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +471 -299
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +135 -94
- package/examples/kitchen-sink/test/identity/identity.test.ts +110 -82
- package/examples/kitchen-sink/test/identity/session.test.ts +65 -43
- package/examples/kitchen-sink/test/issues/assistant.test.ts +50 -31
- package/examples/kitchen-sink/test/issues/events.test.ts +273 -0
- package/examples/kitchen-sink/test/issues/export.test.ts +120 -0
- package/examples/kitchen-sink/test/issues/issues.test.ts +105 -75
- package/examples/kitchen-sink/test/request-policy.test.ts +130 -33
- package/examples/kitchen-sink/test/routes.test.ts +102 -57
- package/examples/kitchen-sink/test/support/as.ts +9 -0
- package/examples/kitchen-sink/test/support/scripted.ts +6 -5
- package/examples/kitchen-sink/test/support/services.ts +17 -0
- package/examples/live-rpc/README.md +33 -35
- package/examples/live-rpc/src/Rpc.ts +9 -5
- package/examples/live-rpc/src/browser.ts +39 -11
- package/examples/live-rpc/src/chat/Chat.ts +30 -16
- package/examples/live-rpc/src/chat/authorize.ts +2 -1
- package/examples/live-rpc/src/chat/events.ts +74 -0
- package/examples/live-rpc/src/chat/handlers.ts +13 -5
- package/examples/live-rpc/src/chat/room.ts +9 -5
- package/examples/live-rpc/src/chat/store.ts +51 -14
- package/examples/live-rpc/src/client.ts +22 -5
- package/examples/live-rpc/src/identity/Identity.ts +3 -3
- package/examples/live-rpc/src/identity/verifier.ts +8 -4
- package/examples/live-rpc/src/page.ts +58 -12
- package/examples/live-rpc/src/routes.ts +14 -10
- package/examples/live-rpc/src/server.ts +15 -5
- package/examples/live-rpc/test/browser.test.ts +127 -50
- package/examples/live-rpc/test/chat/chat.test.ts +215 -23
- package/examples/live-rpc/test/routes.test.ts +130 -46
- package/examples/live-rpc/test/server.test.ts +94 -33
- package/examples/mcp-tools/README.md +24 -38
- package/examples/mcp-tools/src/platform/fake-farm.ts +63 -24
- package/examples/mcp-tools/src/platform/print-farm.ts +59 -29
- package/examples/mcp-tools/src/printers/Printers.ts +18 -7
- package/examples/mcp-tools/src/printers/farm.ts +15 -5
- package/examples/mcp-tools/src/printers/handlers.ts +12 -6
- package/examples/mcp-tools/src/routes.ts +8 -6
- package/examples/mcp-tools/src/server.ts +12 -5
- package/examples/mcp-tools/src/stdio.ts +3 -2
- package/examples/mcp-tools/test/printers/printers.test.ts +132 -89
- package/examples/mcp-tools/test/routes.test.ts +140 -81
- package/examples/notes-service/README.md +30 -45
- package/examples/notes-service/src/Api.ts +1 -0
- package/examples/notes-service/src/browser.ts +15 -14
- package/examples/notes-service/src/cli.ts +8 -3
- package/examples/notes-service/src/commands.ts +46 -20
- package/examples/notes-service/src/identity/Identity.ts +2 -2
- package/examples/notes-service/src/identity/authorize.ts +6 -2
- package/examples/notes-service/src/identity/handlers.ts +3 -2
- package/examples/notes-service/src/identity/users.ts +2 -1
- package/examples/notes-service/src/identity/verifier.ts +12 -6
- package/examples/notes-service/src/notes/Notes.ts +46 -22
- package/examples/notes-service/src/notes/handlers.ts +18 -9
- package/examples/notes-service/src/notes/store.ts +70 -43
- package/examples/notes-service/src/routes.ts +7 -5
- package/examples/notes-service/src/server.ts +12 -5
- package/examples/notes-service/src/status/Status.ts +1 -1
- package/examples/notes-service/src/status/handlers.ts +5 -3
- package/examples/notes-service/src/stdio.ts +7 -3
- package/examples/notes-service/src/worker.ts +6 -3
- package/examples/notes-service/test/commands.test.ts +164 -91
- package/examples/notes-service/test/notes/as.ts +3 -1
- package/examples/notes-service/test/notes/idempotency.test.ts +97 -67
- package/examples/notes-service/test/notes/in-process.test.ts +26 -15
- package/examples/notes-service/test/notes/notes.test.ts +49 -31
- package/examples/notes-service/test/routes.test.ts +106 -71
- package/examples/notes-service/test/worker.test.ts +21 -13
- package/examples/quickstart/README.md +2 -6
- package/examples/quickstart/src/Api.ts +1 -0
- package/examples/quickstart/src/client.ts +6 -3
- package/examples/quickstart/src/greeting/Greeting.ts +1 -1
- package/examples/quickstart/src/greeting/handlers.ts +5 -2
- package/examples/quickstart/src/routes.ts +3 -2
- package/examples/quickstart/src/server.ts +6 -2
- package/examples/quickstart/test/routes.test.ts +3 -2
- package/package.json +19 -17
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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
|
|
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<
|
|
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<
|
|
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:
|
|
58
|
-
|
|
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,
|
|
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)
|
|
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
|
|
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:
|
|
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
|
|
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* (
|
|
11
|
-
|
|
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({
|
|
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
|
|
16
|
-
) => Effect.Effect<
|
|
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
|
|
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,
|
|
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(
|
|
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(
|
|
54
|
+
Effect.mapError(
|
|
55
|
+
() =>
|
|
56
|
+
new Action.Unauthenticated({ message: "Unknown demo session." })
|
|
57
|
+
)
|
|
49
58
|
),
|
|
50
59
|
});
|
|
51
60
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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: {
|
|
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<
|
|
14
|
-
|
|
15
|
-
{
|
|
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<
|
|
20
|
-
|
|
21
|
-
{
|
|
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.
|
|
28
|
-
|
|
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(
|
|
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({
|
|
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<
|
|
23
|
-
|
|
24
|
-
{
|
|
25
|
-
|
|
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<
|
|
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:
|
|
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(
|
|
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: {
|
|
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 = [
|
|
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) =>
|
|
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
|
+
);
|