@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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
3
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
4
|
+
import { Deferred, Effect, Layer } from "effect";
|
|
5
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
6
|
+
import { KeyValueStore } from "effect/persistence";
|
|
7
|
+
|
|
8
|
+
import { Http } from "../src/Api.js";
|
|
9
|
+
import { Notices } from "../src/notifier/Notices.js";
|
|
10
|
+
import { notifier } from "../src/notifier/routes.js";
|
|
11
|
+
import { routes } from "../src/routes.js";
|
|
12
|
+
import { inMemory, settled } from "./support/memory.js";
|
|
13
|
+
|
|
14
|
+
describe("the notifier, by webhook", () => {
|
|
15
|
+
it.effect("is told of each failed build its member may see", () =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
// Both services in memory: build-events POSTs to the notifier's routes, and the notifier
|
|
18
|
+
// calls build-events. Each one's client reaches the other, a cycle no layer builds, so
|
|
19
|
+
// the notifier's client waits for build-events' to be handed over.
|
|
20
|
+
const toBuildEvents = yield* Deferred.make<HttpClient.HttpClient>();
|
|
21
|
+
|
|
22
|
+
const handedOver = Layer.succeed(
|
|
23
|
+
HttpClient.HttpClient,
|
|
24
|
+
HttpClient.make((request) =>
|
|
25
|
+
Effect.flatMap(Deferred.await(toBuildEvents), (client) =>
|
|
26
|
+
client.execute(request)
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const notifierRoutes = notifier({
|
|
32
|
+
buildEvents: "http://build-events.internal",
|
|
33
|
+
token: "grace",
|
|
34
|
+
publicUrl: "https://hooks.example.com",
|
|
35
|
+
}).pipe(Layer.provide(handedOver));
|
|
36
|
+
|
|
37
|
+
// The notices and the watch's cursor are provided around both, so the test reads the ones
|
|
38
|
+
// the notifier writes.
|
|
39
|
+
const services = Testing.layer(routes).pipe(
|
|
40
|
+
Layer.provide(inMemory(Testing.layer(notifierRoutes))),
|
|
41
|
+
Layer.provideMerge(
|
|
42
|
+
Layer.mergeAll(Notices.layerMemory, KeyValueStore.layerMemory)
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
yield* Effect.gen(function* () {
|
|
47
|
+
yield* Deferred.succeed(toBuildEvents, yield* HttpClient.HttpClient);
|
|
48
|
+
|
|
49
|
+
const notices = yield* Notices;
|
|
50
|
+
const cursors = yield* KeyValueStore.KeyValueStore;
|
|
51
|
+
|
|
52
|
+
// The notifier's watch keeps a position once it is subscribed.
|
|
53
|
+
yield* settled(cursors.has("failed-builds"));
|
|
54
|
+
|
|
55
|
+
const ada = yield* ActionHttp.client(Http, {
|
|
56
|
+
transformClient: HttpClient.mapRequest(
|
|
57
|
+
HttpClientRequest.bearerToken("ada")
|
|
58
|
+
),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
yield* ada.finishBuild({ pipeline: "api", status: "failed" });
|
|
62
|
+
yield* ada.finishBuild({ pipeline: "web", status: "passed" });
|
|
63
|
+
// Each build finished notifies `build.finished`: it is POSTed at once, no poll awaited.
|
|
64
|
+
yield* ada.finishBuild({ pipeline: "web", status: "failed" });
|
|
65
|
+
yield* settled(Effect.map(notices.all, (all) => all.length > 0));
|
|
66
|
+
|
|
67
|
+
// grace sees the web pipeline alone, and the notifier asked for failures alone.
|
|
68
|
+
expect(yield* notices.all).toStrictEqual([
|
|
69
|
+
{ id: "build-3", pipeline: "web", status: "failed" },
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
yield* ada.finishBuild({ pipeline: "web", status: "failed" });
|
|
73
|
+
yield* settled(Effect.map(notices.all, (all) => all.length > 1));
|
|
74
|
+
|
|
75
|
+
// Once build-4 is read, build-3 is handled: a restart resumes after the third build.
|
|
76
|
+
expect(yield* cursors.get("failed-builds")).toMatch(/:3$/u);
|
|
77
|
+
}).pipe(Effect.provide(services));
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
4
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
5
|
+
import { Effect, Fiber, Layer, Stream } from "effect";
|
|
6
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
7
|
+
import { TestClock } from "effect/testing";
|
|
8
|
+
|
|
9
|
+
import { BuildFinished, FinishBuild } from "../src/builds/Builds.js";
|
|
10
|
+
import { DeployRequested, RequestDeploy } from "../src/deploys/Deploys.js";
|
|
11
|
+
import { Login } from "../src/identity/Identity.js";
|
|
12
|
+
import { routes } from "../src/routes.js";
|
|
13
|
+
import { inMemory } from "./support/memory.js";
|
|
14
|
+
|
|
15
|
+
// What the wire adds: the real routes, behind their real authentication, answering in memory.
|
|
16
|
+
// No port is opened. The subscriber is the token each client sends, as in production.
|
|
17
|
+
const served = Testing.layer(routes).pipe(Layer.provide(inMemory()));
|
|
18
|
+
|
|
19
|
+
const as = (token: string) => ({
|
|
20
|
+
authentication: Login,
|
|
21
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("the service, over MCP", () => {
|
|
25
|
+
it.effect(
|
|
26
|
+
"pushes each build a member may see, as a tool call finishes it",
|
|
27
|
+
() =>
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
// A client lists the contracts it uses: the action it calls, the event it watches.
|
|
30
|
+
const ada = yield* ActionMcp.client([FinishBuild], as("ada"));
|
|
31
|
+
const grace = yield* ActionMcp.client([BuildFinished], as("grace"));
|
|
32
|
+
|
|
33
|
+
const watched = yield* grace
|
|
34
|
+
.watch(BuildFinished)
|
|
35
|
+
.pipe(Stream.take(1), Stream.runCollect, Effect.forkChild);
|
|
36
|
+
|
|
37
|
+
// The watch starts from now: let it take its first position before builds finish.
|
|
38
|
+
yield* TestClock.adjust("0 millis");
|
|
39
|
+
yield* ada.finishBuild({ pipeline: "api", status: "passed" });
|
|
40
|
+
// Each build finished notifies `build.finished`: the server asks the store at once, and
|
|
41
|
+
// pushes what it answers.
|
|
42
|
+
yield* ada.finishBuild({ pipeline: "web", status: "passed" });
|
|
43
|
+
|
|
44
|
+
expect(yield* Fiber.join(watched)).toMatchObject([
|
|
45
|
+
{ _tag: "Occurrence", data: { id: "build-2", pipeline: "web" } },
|
|
46
|
+
]);
|
|
47
|
+
}).pipe(Effect.provide(served))
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
it.effect(
|
|
51
|
+
"delivers deploy requests to a member that polls, standing them by meanwhile",
|
|
52
|
+
() =>
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
const ada = yield* ActionMcp.client([RequestDeploy], as("ada"));
|
|
55
|
+
const grace = yield* ActionMcp.client([DeployRequested], as("grace"));
|
|
56
|
+
|
|
57
|
+
// `mode: "poll"` asks with `events/poll`, as a client that cannot hold a stream does.
|
|
58
|
+
const polled = yield* grace
|
|
59
|
+
.watch(
|
|
60
|
+
DeployRequested,
|
|
61
|
+
{ environment: "production" },
|
|
62
|
+
{ mode: "poll" }
|
|
63
|
+
)
|
|
64
|
+
.pipe(Stream.take(1), Stream.runCollect, Effect.forkChild);
|
|
65
|
+
|
|
66
|
+
yield* TestClock.adjust("0 millis");
|
|
67
|
+
|
|
68
|
+
const deploy = yield* ada.requestDeploy({
|
|
69
|
+
environment: "production",
|
|
70
|
+
version: "1.0.9",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
yield* TestClock.adjust("5 seconds");
|
|
74
|
+
|
|
75
|
+
// Between polls, grace stands by: the server holds the subscription hook for 5 minutes
|
|
76
|
+
// after each poll, keyed by grace's principal.
|
|
77
|
+
expect(deploy.approvers).toStrictEqual(["grace"]);
|
|
78
|
+
expect(yield* Fiber.join(polled)).toMatchObject([
|
|
79
|
+
{
|
|
80
|
+
_tag: "Occurrence",
|
|
81
|
+
data: { version: "1.0.9", approvers: ["grace"] },
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
}).pipe(Effect.provide(served))
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
it.effect("refuses a watch without a member's token", () =>
|
|
88
|
+
Effect.gen(function* () {
|
|
89
|
+
const stranger = yield* ActionMcp.client([BuildFinished], as("mallory"));
|
|
90
|
+
|
|
91
|
+
expect(
|
|
92
|
+
yield* Effect.flip(Stream.runDrain(stranger.watch(BuildFinished)))
|
|
93
|
+
).toBeInstanceOf(Action.Unauthenticated);
|
|
94
|
+
}).pipe(Effect.provide(served))
|
|
95
|
+
);
|
|
96
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
4
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
5
|
+
import { Effect, Fiber, Layer, Stream } from "effect";
|
|
6
|
+
import { RpcClient, RpcSerialization } from "effect/rpc";
|
|
7
|
+
import { TestClock } from "effect/testing";
|
|
8
|
+
|
|
9
|
+
import { BuildFinished } from "../src/builds/Builds.js";
|
|
10
|
+
import { routes } from "../src/routes.js";
|
|
11
|
+
import { Rpc } from "../src/Rpc.js";
|
|
12
|
+
import { inMemory } from "./support/memory.js";
|
|
13
|
+
|
|
14
|
+
// Effect's RPC client over HTTP to the real routes in memory, with the serialization the
|
|
15
|
+
// server frames streams with.
|
|
16
|
+
const served = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
|
|
17
|
+
Layer.provide(RpcSerialization.layerNdjson),
|
|
18
|
+
Layer.provide(Testing.layer(routes)),
|
|
19
|
+
Layer.provide(inMemory())
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// An rpc's credential is its own headers: each call or watch says who is calling.
|
|
23
|
+
const as = (token: string) =>
|
|
24
|
+
RpcClient.withHeaders({ authorization: `Bearer ${token}` });
|
|
25
|
+
|
|
26
|
+
describe("the service, over RPC", () => {
|
|
27
|
+
it.effect("streams each build a member may see, as a call finishes it", () =>
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const client = yield* ActionRpc.client(Rpc);
|
|
30
|
+
|
|
31
|
+
const watched = yield* client
|
|
32
|
+
.watch(BuildFinished)
|
|
33
|
+
.pipe(Stream.take(1), Stream.runCollect, as("grace"), Effect.forkChild);
|
|
34
|
+
|
|
35
|
+
// The watch starts from now: let it take its first position before builds finish.
|
|
36
|
+
yield* TestClock.adjust("0 millis");
|
|
37
|
+
yield* client
|
|
38
|
+
.finishBuild({ pipeline: "api", status: "passed" })
|
|
39
|
+
.pipe(as("ada"));
|
|
40
|
+
yield* client
|
|
41
|
+
.finishBuild({ pipeline: "web", status: "passed" })
|
|
42
|
+
.pipe(as("ada"));
|
|
43
|
+
|
|
44
|
+
expect(yield* Fiber.join(watched)).toMatchObject([
|
|
45
|
+
{ _tag: "Occurrence", data: { id: "build-2", pipeline: "web" } },
|
|
46
|
+
]);
|
|
47
|
+
}).pipe(Effect.scoped, Effect.provide(served))
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
it.effect(
|
|
51
|
+
"refuses a watch without a member's token, as its typed failure",
|
|
52
|
+
() =>
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
const client = yield* ActionRpc.client(Rpc);
|
|
55
|
+
|
|
56
|
+
expect(
|
|
57
|
+
yield* Effect.flip(Stream.runDrain(client.watch(BuildFinished))).pipe(
|
|
58
|
+
as("mallory")
|
|
59
|
+
)
|
|
60
|
+
).toBeInstanceOf(Action.Unauthenticated);
|
|
61
|
+
}).pipe(Effect.scoped, Effect.provide(served))
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
it.effect(
|
|
65
|
+
"streams each step of a build a member starts, then the build",
|
|
66
|
+
() =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
const client = yield* ActionRpc.client(Rpc);
|
|
69
|
+
const steps: string[] = [];
|
|
70
|
+
|
|
71
|
+
const build = yield* client
|
|
72
|
+
.startBuild(
|
|
73
|
+
{ pipeline: "api" },
|
|
74
|
+
{ onProgress: ({ step }) => Effect.sync(() => steps.push(step)) }
|
|
75
|
+
)
|
|
76
|
+
.pipe(as("ada"));
|
|
77
|
+
|
|
78
|
+
expect([steps, build]).toStrictEqual([
|
|
79
|
+
["install", "build", "test"],
|
|
80
|
+
{ id: "build-1", pipeline: "api", status: "passed" },
|
|
81
|
+
]);
|
|
82
|
+
}).pipe(Effect.scoped, Effect.provide(served))
|
|
83
|
+
);
|
|
84
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import { CurrentMember } from "../../src/identity/Identity.js";
|
|
4
|
+
import { members } from "../../src/identity/members.js";
|
|
5
|
+
|
|
6
|
+
// The caller of one call or subscription, as a server provides it per request.
|
|
7
|
+
export const as = (member: keyof typeof members) =>
|
|
8
|
+
Effect.provideService(CurrentMember, members[member]);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { setImmediate } from "node:timers/promises";
|
|
2
|
+
|
|
3
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
4
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
5
|
+
import { Effect, Layer } from "effect";
|
|
6
|
+
import type { HttpClient } from "effect/http";
|
|
7
|
+
import { Dns } from "effect/net";
|
|
8
|
+
import { KeyValueStore } from "effect/persistence";
|
|
9
|
+
|
|
10
|
+
import { BuildStore } from "../../src/builds/store.js";
|
|
11
|
+
import { Approvers } from "../../src/deploys/approvers.js";
|
|
12
|
+
|
|
13
|
+
const unreached = async () => new Response(null, { status: 503 });
|
|
14
|
+
|
|
15
|
+
// What the routes need, in memory: the stores, the events announced, and for webhook delivery a client, a resolver
|
|
16
|
+
// and a store for the subscriptions. `subscribers` answers each POST in place of a member's server, and
|
|
17
|
+
// hooks.example.com resolves to a public address: delivery refuses a private or loopback one.
|
|
18
|
+
export const inMemory = <R = never>(
|
|
19
|
+
subscribers: Layer.Layer<HttpClient.HttpClient, never, R> = Testing.layer(
|
|
20
|
+
unreached
|
|
21
|
+
)
|
|
22
|
+
) =>
|
|
23
|
+
Layer.mergeAll(
|
|
24
|
+
BuildStore.layerMemory,
|
|
25
|
+
Approvers.layerMemory,
|
|
26
|
+
Event.layerMemory,
|
|
27
|
+
subscribers,
|
|
28
|
+
Dns.layerStatic({ hosts: { "hooks.example.com": ["93.184.216.34"] } }),
|
|
29
|
+
KeyValueStore.layerMemory
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Waits for `done` while requests in memory resolve: a subscription made through them runs on
|
|
33
|
+
// promises the test clock does not drive.
|
|
34
|
+
export const settled = <E, R>(done: Effect.Effect<boolean, E, R>) =>
|
|
35
|
+
Effect.repeat(
|
|
36
|
+
Effect.andThen(
|
|
37
|
+
Effect.promise(async () => {
|
|
38
|
+
await setImmediate();
|
|
39
|
+
}),
|
|
40
|
+
done
|
|
41
|
+
),
|
|
42
|
+
{ until: (finished) => finished, times: 200 }
|
|
43
|
+
);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
3
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
4
|
+
import { Effect, Layer, Queue, Schema } from "effect";
|
|
5
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
6
|
+
|
|
7
|
+
import { Http } from "../src/Api.js";
|
|
8
|
+
import { routes } from "../src/routes.js";
|
|
9
|
+
import { inMemory } from "./support/memory.js";
|
|
10
|
+
|
|
11
|
+
// The subscriber's secret: `whsec_` and 24 to 64 random bytes in base64. The server signs each
|
|
12
|
+
// POST with it, and the subscriber checks each POST it receives against it.
|
|
13
|
+
const secret = `whsec_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64")}`;
|
|
14
|
+
|
|
15
|
+
// A subscriber's check, as Standard Webhooks defines it: an HMAC-SHA256 of the id, the
|
|
16
|
+
// timestamp and the body exactly as received, among the space-separated `v1,` signatures.
|
|
17
|
+
const signed = async (request: Request, body: string) => {
|
|
18
|
+
const key = await crypto.subtle.importKey(
|
|
19
|
+
"raw",
|
|
20
|
+
Buffer.from(secret.slice("whsec_".length), "base64"),
|
|
21
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
22
|
+
false,
|
|
23
|
+
["sign"]
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const signedContent = [
|
|
27
|
+
request.headers.get("webhook-id"),
|
|
28
|
+
request.headers.get("webhook-timestamp"),
|
|
29
|
+
body,
|
|
30
|
+
].join(".");
|
|
31
|
+
|
|
32
|
+
const expected = Buffer.from(
|
|
33
|
+
await crypto.subtle.sign(
|
|
34
|
+
"HMAC",
|
|
35
|
+
key,
|
|
36
|
+
new TextEncoder().encode(signedContent)
|
|
37
|
+
)
|
|
38
|
+
).toString("base64");
|
|
39
|
+
|
|
40
|
+
return (request.headers.get("webhook-signature") ?? "")
|
|
41
|
+
.split(" ")
|
|
42
|
+
.includes(`v1,${expected}`);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const Delivered = Schema.fromJsonString(
|
|
46
|
+
Schema.Record(Schema.String, Schema.Json)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
interface Received {
|
|
50
|
+
readonly body: typeof Delivered.Type;
|
|
51
|
+
readonly signed: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// grace's server: it answers the verification POST by echoing its challenge, which proves it
|
|
55
|
+
// asked for these deliveries, and acknowledges each delivery with a 2xx.
|
|
56
|
+
const subscriber =
|
|
57
|
+
(received: Queue.Enqueue<Received>) => async (request: Request) => {
|
|
58
|
+
const text = await request.text();
|
|
59
|
+
const body = Schema.decodeSync(Delivered)(text);
|
|
60
|
+
|
|
61
|
+
Queue.offerUnsafe(received, { body, signed: await signed(request, text) });
|
|
62
|
+
|
|
63
|
+
return body["type"] === "verification"
|
|
64
|
+
? Response.json({ challenge: body["challenge"] })
|
|
65
|
+
: new Response(null);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const served = (received: Queue.Enqueue<Received>) =>
|
|
69
|
+
Testing.layer(routes).pipe(
|
|
70
|
+
Layer.provide(inMemory(Testing.layer(subscriber(received))))
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const Subscribed = Schema.Struct({
|
|
74
|
+
id: Schema.String,
|
|
75
|
+
refreshBefore: Schema.NullOr(Schema.String),
|
|
76
|
+
deliveryStatus: Schema.optional(
|
|
77
|
+
Schema.Struct({
|
|
78
|
+
active: Schema.Boolean,
|
|
79
|
+
lastError: Schema.NullOr(Schema.String),
|
|
80
|
+
})
|
|
81
|
+
),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const subscribe = Effect.flatMap(HttpClient.HttpClient, (client) =>
|
|
85
|
+
client.execute(
|
|
86
|
+
HttpClientRequest.post("/api/events/build.finished/subscribe").pipe(
|
|
87
|
+
HttpClientRequest.bearerToken("grace"),
|
|
88
|
+
HttpClientRequest.bodyJsonUnsafe({
|
|
89
|
+
input: { status: "passed" },
|
|
90
|
+
delivery: { url: "https://hooks.example.com/grace", secret },
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
).pipe(
|
|
95
|
+
Effect.flatMap((response) => response.json),
|
|
96
|
+
Effect.flatMap(Schema.decodeUnknownEffect(Subscribed))
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
describe("the service, by webhook", () => {
|
|
100
|
+
it.effect("POSTs each build a member may see to its callback, signed", () =>
|
|
101
|
+
Effect.flatMap(Queue.unbounded<Received>(), (received) =>
|
|
102
|
+
Effect.gen(function* () {
|
|
103
|
+
const ada = yield* ActionHttp.client(Http, {
|
|
104
|
+
transformClient: HttpClient.mapRequest(
|
|
105
|
+
HttpClientRequest.bearerToken("ada")
|
|
106
|
+
),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// The answer names the subscription, which each POST's `x-mcp-subscription-id`
|
|
110
|
+
// carries, and when to subscribe again to keep it: one not refreshed by then ends.
|
|
111
|
+
const subscription = yield* subscribe;
|
|
112
|
+
const verification = yield* Queue.take(received);
|
|
113
|
+
|
|
114
|
+
yield* ada.finishBuild({ pipeline: "api", status: "passed" });
|
|
115
|
+
// Each build finished notifies `build.finished`: the server asks the store at once, and
|
|
116
|
+
// POSTs what it answers.
|
|
117
|
+
yield* ada.finishBuild({ pipeline: "web", status: "passed" });
|
|
118
|
+
|
|
119
|
+
// grace sees only the web pipeline.
|
|
120
|
+
const delivery = yield* Queue.take(received);
|
|
121
|
+
|
|
122
|
+
// The same request again refreshes the subscription, and answers how it is going.
|
|
123
|
+
const refreshed = yield* subscribe;
|
|
124
|
+
|
|
125
|
+
expect(verification).toMatchObject({
|
|
126
|
+
body: { type: "verification" },
|
|
127
|
+
signed: true,
|
|
128
|
+
});
|
|
129
|
+
expect(delivery).toMatchObject({
|
|
130
|
+
body: {
|
|
131
|
+
name: "build.finished",
|
|
132
|
+
data: { id: "build-2", pipeline: "web" },
|
|
133
|
+
},
|
|
134
|
+
signed: true,
|
|
135
|
+
});
|
|
136
|
+
expect(refreshed).toMatchObject({
|
|
137
|
+
id: subscription.id,
|
|
138
|
+
deliveryStatus: { active: true, lastError: null },
|
|
139
|
+
});
|
|
140
|
+
}).pipe(Effect.provide(served(received)))
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
});
|