@gjermundgaraba/effect-actions 0.20.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 -46
- package/dist/Action.mjs +87 -42
- package/dist/ActionCli.d.mts +69 -33
- package/dist/ActionCli.mjs +51 -32
- package/dist/ActionHttp.d.mts +69 -35
- package/dist/ActionHttp.mjs +75 -23
- package/dist/ActionMcp.d.mts +56 -22
- package/dist/ActionMcp.mjs +94 -69
- package/dist/ActionRpc.d.mts +222 -55
- package/dist/ActionRpc.mjs +299 -46
- package/dist/ActionToolkit.d.mts +22 -5
- package/dist/ActionToolkit.mjs +36 -12
- package/dist/Authentication.d.mts +42 -10
- package/dist/Authentication.mjs +37 -30
- package/dist/Event.d.mts +370 -0
- package/dist/Event.mjs +103 -0
- package/dist/Testing.mjs +1 -1
- package/dist/Webhook.d.mts +146 -0
- package/dist/Webhook.mjs +72 -0
- package/dist/authentication/provider.d.mts +35 -10
- package/dist/authentication/provider.mjs +1 -1
- package/dist/authentication/refusal.mjs +2 -2
- package/dist/cli/commands.d.mts +9 -5
- package/dist/cli/commands.mjs +84 -33
- package/dist/contract/ast.mjs +4 -1
- package/dist/contract/authorizer.d.mts +8 -0
- package/dist/contract/authorizer.mjs +20 -0
- package/dist/contract/call.d.mts +9 -6
- package/dist/contract/call.mjs +48 -6
- package/dist/contract/callback.d.mts +7 -0
- package/dist/contract/callback.mjs +27 -0
- package/dist/contract/caller.d.mts +13 -2
- package/dist/contract/caller.mjs +22 -3
- 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.mjs +25 -9
- package/dist/contract/digest.mjs +6 -0
- package/dist/contract/errors.d.mts +9 -3
- package/dist/contract/errors.mjs +1 -1
- package/dist/contract/feed.d.mts +127 -0
- package/dist/contract/feed.mjs +249 -0
- 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 -47
- package/dist/contract/implementation.mjs +92 -58
- 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/names.mjs +14 -4
- 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 +14 -5
- 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 -12
- package/dist/http/client.mjs +51 -12
- 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 +1 -1
- 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 -97
- package/dist/mcp/client.mjs +59 -132
- package/dist/mcp/events.mjs +142 -0
- package/dist/mcp/progress.mjs +34 -0
- package/dist/mcp/protocol.mjs +39 -13
- 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 +13 -5
- package/dist/testing/memory.d.mts +1 -1
- package/dist/testing/memory.mjs +3 -3
- 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 +52 -60
- 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 -52
- 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 +21 -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 +108 -47
- 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 +10 -4
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
|
|
3
|
+
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
|
|
4
|
+
import { ByteSize, Config, Effect, Layer } from "effect";
|
|
5
|
+
import { FetchHttpClient, HttpRouter, HttpServerRequest } from "effect/http";
|
|
6
|
+
import { KeyValueStore } from "effect/persistence";
|
|
7
|
+
|
|
8
|
+
import { Notices } from "./notifier/Notices.js";
|
|
9
|
+
import { notifier } from "./notifier/routes.js";
|
|
10
|
+
|
|
11
|
+
// Entrypoint of the second service: `BUILDS_TOKEN=grace PUBLIC_URL=https://<its public name>
|
|
12
|
+
// node --import tsx examples/build-events/src/notifier.ts`, with server.ts running. build-events
|
|
13
|
+
// POSTs only to a globally reachable address, so on one machine give it a tunnel's name.
|
|
14
|
+
const server = Layer.unwrap(
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const buildEvents = yield* Config.String("BUILDS_URL").pipe(
|
|
17
|
+
Config.withDefault("http://127.0.0.1:3000")
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// Redacted, so a log or an error printing the configuration never shows the token.
|
|
21
|
+
const token = yield* Config.Redacted("BUILDS_TOKEN");
|
|
22
|
+
const publicUrl = yield* Config.String("PUBLIC_URL");
|
|
23
|
+
|
|
24
|
+
// The loopback default serves this machine alone, which a tunnel reaches; HOST=0.0.0.0 lets
|
|
25
|
+
// build-events reach it directly.
|
|
26
|
+
const host = yield* Config.String("HOST").pipe(
|
|
27
|
+
Config.withDefault("127.0.0.1")
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const port = yield* Config.Port("PORT").pipe(Config.withDefault(3001));
|
|
31
|
+
|
|
32
|
+
return HttpRouter.serve(notifier({ buildEvents, token, publicUrl })).pipe(
|
|
33
|
+
Layer.provide(NodeHttpServer.layer(createServer, { host, port })),
|
|
34
|
+
// The receiver's route is public and reads each body whole: a delivery is at most 256 KiB.
|
|
35
|
+
Layer.provide(
|
|
36
|
+
Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.kibibytes(256))
|
|
37
|
+
),
|
|
38
|
+
// DEMO ONLY: notices and the watch's cursor live in this process's memory.
|
|
39
|
+
Layer.provide([
|
|
40
|
+
Notices.layerMemory,
|
|
41
|
+
KeyValueStore.layerMemory,
|
|
42
|
+
FetchHttpClient.layer,
|
|
43
|
+
])
|
|
44
|
+
);
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
Layer.launch(server).pipe(NodeRuntime.runMain());
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
2
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
3
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
4
|
+
import * as Webhook from "@gjermundgaraba/effect-actions/Webhook";
|
|
5
|
+
import { Layer } from "effect";
|
|
6
|
+
import { RpcSerialization, RpcServer } from "effect/rpc";
|
|
7
|
+
|
|
8
|
+
import { Http } from "./Api.js";
|
|
9
|
+
import { buildEvents } from "./builds/events.js";
|
|
10
|
+
import { builds } from "./builds/handlers.js";
|
|
11
|
+
import { logs } from "./builds/log.js";
|
|
12
|
+
import { statusEvents } from "./builds/status.js";
|
|
13
|
+
import { deployEvents } from "./deploys/events.js";
|
|
14
|
+
import { deploys } from "./deploys/handlers.js";
|
|
15
|
+
import { Login } from "./identity/Identity.js";
|
|
16
|
+
import { authenticate } from "./identity/verifier.js";
|
|
17
|
+
import { Rpc } from "./Rpc.js";
|
|
18
|
+
|
|
19
|
+
// Every domain's implementation, once: the events beside the actions, in one list.
|
|
20
|
+
const implementations = [
|
|
21
|
+
builds,
|
|
22
|
+
logs,
|
|
23
|
+
buildEvents,
|
|
24
|
+
statusEvents,
|
|
25
|
+
deploys,
|
|
26
|
+
deployEvents,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// The actions as MCP tools and the events as MCP events, at `/mcp`. A client polls with
|
|
30
|
+
// `events/poll`, or holds `events/stream` open and is pushed each occurrence. Both events are
|
|
31
|
+
// protected, so each request needs a member's token, and each subscriber sees what its token
|
|
32
|
+
// lets it. This long-lived process holds the deploy event's `subscription` hook between polls and
|
|
33
|
+
// its log in memory, and pushes each occurrence: a `requestScoped` endpoint would serve the events
|
|
34
|
+
// by poll and webhook alone, and a serverless host, where no process lasts, check sources alone,
|
|
35
|
+
// without `Webhook.layer`.
|
|
36
|
+
//
|
|
37
|
+
// A tool answers one result, so `tailLog` would collect a whole log before answering, all of it
|
|
38
|
+
// in a model's context: its implementation, `logs`, is left out, and the endpoint serves every
|
|
39
|
+
// other action and every event.
|
|
40
|
+
const mcp = ActionMcp.layerHttp(
|
|
41
|
+
[builds, buildEvents, statusEvents, deploys, deployEvents],
|
|
42
|
+
{
|
|
43
|
+
name: "build-events",
|
|
44
|
+
version: "1.0.0",
|
|
45
|
+
authentication: Login,
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// The same implementations as routes under `/api`, for a client that speaks HTTP rather than
|
|
50
|
+
// MCP: one implementation of each event serves every surface.
|
|
51
|
+
const api = ActionHttp.layer(Http, implementations);
|
|
52
|
+
|
|
53
|
+
// And as Effect RPC at `/rpc`, over HTTP. A watch is a stream rpc that never ends, so the
|
|
54
|
+
// serialization frames each element as it is sent: `layerNdjson` does, where `layerJson` would
|
|
55
|
+
// answer once every stream of the request ended, which a watch never does.
|
|
56
|
+
const rpc = ActionRpc.layer(Rpc, implementations).pipe(
|
|
57
|
+
Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
|
|
58
|
+
Layer.provide(RpcSerialization.layerNdjson)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Webhook delivery for MCP and HTTP alike: a member names a callback URL and a secret, with
|
|
62
|
+
// MCP's `events/subscribe` or `POST /api/events/<name>/subscribe`, and each occurrence it may see
|
|
63
|
+
// is POSTed there, signed. One layer holds the subscriptions of both, so either surface refreshes
|
|
64
|
+
// one made over the other. `persist` keeps each in a `KeyValueStore`, so a restart goes on
|
|
65
|
+
// delivering from where it reached, as the subscriber `Login`'s `schema` keeps. It needs an
|
|
66
|
+
// `HttpClient` to POST, `Dns` to check where each callback resolves, and the store, which
|
|
67
|
+
// `server.ts` provides.
|
|
68
|
+
export const routes = Layer.mergeAll(mcp, api, rpc).pipe(
|
|
69
|
+
Layer.provide(authenticate),
|
|
70
|
+
Layer.provide(Webhook.layer({ persist: true }))
|
|
71
|
+
);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
NodeDns,
|
|
5
|
+
NodeFileSystem,
|
|
6
|
+
NodeHttpServer,
|
|
7
|
+
NodePath,
|
|
8
|
+
NodeRuntime,
|
|
9
|
+
} from "@effect/platform-node";
|
|
10
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
11
|
+
import { ByteSize, Config, Effect, Layer } from "effect";
|
|
12
|
+
import { FetchHttpClient, HttpRouter, HttpServerRequest } from "effect/http";
|
|
13
|
+
import { KeyValueStore } from "effect/persistence";
|
|
14
|
+
|
|
15
|
+
import { BuildStore } from "./builds/store.js";
|
|
16
|
+
import { Approvers } from "./deploys/approvers.js";
|
|
17
|
+
import { routes } from "./routes.js";
|
|
18
|
+
|
|
19
|
+
// Entrypoint: `node --import tsx examples/build-events/src/server.ts`.
|
|
20
|
+
// It chooses the stores, reads the configuration and starts Node's HTTP server.
|
|
21
|
+
const server = Layer.unwrap(
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
// The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
|
|
24
|
+
const host = yield* Config.String("HOST").pipe(
|
|
25
|
+
Config.withDefault("127.0.0.1")
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const port = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
|
|
29
|
+
|
|
30
|
+
const webhooks = yield* Config.String("WEBHOOKS_DIR").pipe(
|
|
31
|
+
Config.withDefault(".build-events/webhooks")
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return HttpRouter.serve(routes).pipe(
|
|
35
|
+
Layer.provide(NodeHttpServer.layer(createServer, { host, port })),
|
|
36
|
+
// Request bodies have no size limit unless the host sets one. This covers every route
|
|
37
|
+
// that reads its body whole, the MCP endpoint and the webhook subscribe routes included.
|
|
38
|
+
Layer.provide(
|
|
39
|
+
Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.mebibytes(1))
|
|
40
|
+
),
|
|
41
|
+
// DEMO ONLY: builds and approvers live in this process's memory; a deployment provides
|
|
42
|
+
// stores that last.
|
|
43
|
+
Layer.provide([BuildStore.layerMemory, Approvers.layerMemory]),
|
|
44
|
+
// The events this process announces: a notify after a build finishes, and each deploy
|
|
45
|
+
// request published. One for the whole server, which holds every subscriber's connection.
|
|
46
|
+
Layer.provide(Event.layerMemory),
|
|
47
|
+
// Webhook subscriptions are kept as files under WEBHOOKS_DIR, so a restart goes on
|
|
48
|
+
// delivering them; after one, the memory store above sends each a gap first. The files
|
|
49
|
+
// hold each subscriber's secret: give the directory to this server alone, and each server
|
|
50
|
+
// process its own, since the layer is its store's one writer.
|
|
51
|
+
Layer.provide(
|
|
52
|
+
KeyValueStore.layerFileSystem(webhooks).pipe(
|
|
53
|
+
Layer.provide([NodeFileSystem.layer, NodePath.layer])
|
|
54
|
+
)
|
|
55
|
+
),
|
|
56
|
+
// Webhook delivery POSTs with fetch, and resolves each callback with the system's resolver.
|
|
57
|
+
Layer.provide([FetchHttpClient.layer, NodeDns.layer])
|
|
58
|
+
);
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
Layer.launch(server).pipe(NodeRuntime.runMain());
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { expect, it } from "@effect/vitest";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
4
|
+
import { Effect, Fiber, Stream } from "effect";
|
|
5
|
+
import { TestClock } from "effect/testing";
|
|
6
|
+
|
|
7
|
+
import { BuildFinished } from "../../src/builds/Builds.js";
|
|
8
|
+
import { buildEvents } from "../../src/builds/events.js";
|
|
9
|
+
import { builds } from "../../src/builds/handlers.js";
|
|
10
|
+
import { BuildStore } from "../../src/builds/store.js";
|
|
11
|
+
import { as } from "../support/as.js";
|
|
12
|
+
|
|
13
|
+
it.effect(
|
|
14
|
+
"delivers each build a member may see as it finishes, as the subscription selects",
|
|
15
|
+
() =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
// One client holds both: the action that finishes builds and the event that reports them.
|
|
18
|
+
const client = yield* Action.client([builds, buildEvents]);
|
|
19
|
+
|
|
20
|
+
const failed = yield* client
|
|
21
|
+
.watch(BuildFinished, { status: "failed" })
|
|
22
|
+
.pipe(Stream.take(1), Stream.runCollect, as("grace"), Effect.forkChild);
|
|
23
|
+
|
|
24
|
+
// The watch starts from now: let it take its first position before builds finish.
|
|
25
|
+
yield* TestClock.adjust("0 millis");
|
|
26
|
+
yield* client
|
|
27
|
+
.finishBuild({ pipeline: "api", status: "failed" })
|
|
28
|
+
.pipe(as("ada"));
|
|
29
|
+
yield* client
|
|
30
|
+
.finishBuild({ pipeline: "web", status: "passed" })
|
|
31
|
+
.pipe(as("ada"));
|
|
32
|
+
// Each build finished notifies: the watch reads at once, with no wait for its next poll.
|
|
33
|
+
yield* client
|
|
34
|
+
.finishBuild({ pipeline: "web", status: "failed" })
|
|
35
|
+
.pipe(as("ada"));
|
|
36
|
+
|
|
37
|
+
// grace sees only the web pipeline, and asked only for failures.
|
|
38
|
+
expect(yield* Fiber.join(failed)).toMatchObject([
|
|
39
|
+
{
|
|
40
|
+
_tag: "Occurrence",
|
|
41
|
+
data: { id: "build-3", pipeline: "web", status: "failed" },
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
}).pipe(Effect.provide([BuildStore.layerMemory, Event.layerMemory]))
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
it.effect(
|
|
48
|
+
"sends a gap first to a subscriber resuming from a cursor the store never gave",
|
|
49
|
+
() =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
const client = yield* Action.client([builds, buildEvents]);
|
|
52
|
+
|
|
53
|
+
yield* client
|
|
54
|
+
.finishBuild({ pipeline: "web", status: "passed" })
|
|
55
|
+
.pipe(as("ada"));
|
|
56
|
+
|
|
57
|
+
// A cursor of the store before a restart, its offset one this store has too: what it named
|
|
58
|
+
// is gone, so the watch is told.
|
|
59
|
+
const resumed = yield* client
|
|
60
|
+
.watch(BuildFinished, {}, { cursor: "an-earlier-epoch:0" })
|
|
61
|
+
.pipe(Stream.take(2), Stream.runCollect, as("grace"));
|
|
62
|
+
|
|
63
|
+
expect(resumed).toMatchObject([
|
|
64
|
+
{ _tag: "Gap" },
|
|
65
|
+
{ _tag: "Occurrence", data: { id: "build-1", pipeline: "web" } },
|
|
66
|
+
]);
|
|
67
|
+
}).pipe(Effect.provide([BuildStore.layerMemory, Event.layerMemory]))
|
|
68
|
+
);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
3
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
4
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
5
|
+
import { Effect, Exit, Layer, Stream } from "effect";
|
|
6
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
7
|
+
import { RpcClient, RpcSerialization } from "effect/rpc";
|
|
8
|
+
|
|
9
|
+
import { Http } from "../../src/Api.js";
|
|
10
|
+
import { BuildNotFound } from "../../src/builds/Builds.js";
|
|
11
|
+
import { routes } from "../../src/routes.js";
|
|
12
|
+
import { Rpc } from "../../src/Rpc.js";
|
|
13
|
+
import { inMemory } from "../support/memory.js";
|
|
14
|
+
|
|
15
|
+
const served = Testing.layer(routes).pipe(Layer.provide(inMemory()));
|
|
16
|
+
|
|
17
|
+
const overRpc = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
|
|
18
|
+
Layer.provide(RpcSerialization.layerNdjson),
|
|
19
|
+
Layer.provide(served)
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const bearer = (token: string) =>
|
|
23
|
+
HttpClient.mapRequest(HttpClientRequest.bearerToken(token));
|
|
24
|
+
|
|
25
|
+
const log = [
|
|
26
|
+
{ line: "install: passed" },
|
|
27
|
+
{ line: "build: passed" },
|
|
28
|
+
{ line: "test: passed" },
|
|
29
|
+
{ line: "passed" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
describe("a build's log", () => {
|
|
33
|
+
it.effect("streams each line of a build over HTTP and RPC", () =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const http = yield* ActionHttp.client(Http, {
|
|
36
|
+
transformClient: bearer("ada"),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const rpc = yield* ActionRpc.client(Rpc);
|
|
40
|
+
const { id } = yield* http.startBuild({ pipeline: "api" });
|
|
41
|
+
|
|
42
|
+
// Each call returns a `Stream`: a line is read as the server sends it.
|
|
43
|
+
const lines = [
|
|
44
|
+
yield* Stream.runCollect(http.tailLog({ buildId: id })),
|
|
45
|
+
yield* Stream.runCollect(rpc.tailLog({ buildId: id })).pipe(
|
|
46
|
+
RpcClient.withHeaders({ authorization: "Bearer ada" })
|
|
47
|
+
),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
expect(lines).toStrictEqual([log, log]);
|
|
51
|
+
}).pipe(Effect.scoped, Effect.provide(Layer.merge(served, overRpc)))
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
it.effect("is not found for a member who may not see its pipeline", () =>
|
|
55
|
+
Effect.gen(function* () {
|
|
56
|
+
const ada = yield* ActionHttp.client(Http, {
|
|
57
|
+
transformClient: bearer("ada"),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const grace = yield* ActionHttp.client(Http, {
|
|
61
|
+
transformClient: bearer("grace"),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const { id } = yield* ada.startBuild({ pipeline: "api" });
|
|
65
|
+
|
|
66
|
+
expect(
|
|
67
|
+
yield* Effect.exit(Stream.runDrain(grace.tailLog({ buildId: id })))
|
|
68
|
+
).toStrictEqual(Exit.fail(new BuildNotFound({ buildId: id })));
|
|
69
|
+
}).pipe(Effect.provide(served))
|
|
70
|
+
);
|
|
71
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
3
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
4
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
5
|
+
import { Effect, Layer } from "effect";
|
|
6
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
7
|
+
|
|
8
|
+
import { Http } from "../../src/Api.js";
|
|
9
|
+
import { StartBuild } from "../../src/builds/Builds.js";
|
|
10
|
+
import { Login } from "../../src/identity/Identity.js";
|
|
11
|
+
import { routes } from "../../src/routes.js";
|
|
12
|
+
import { inMemory } from "../support/memory.js";
|
|
13
|
+
|
|
14
|
+
const served = Testing.layer(routes).pipe(Layer.provide(inMemory()));
|
|
15
|
+
|
|
16
|
+
const bearer = (token: string) =>
|
|
17
|
+
HttpClient.mapRequest(HttpClientRequest.bearerToken(token));
|
|
18
|
+
|
|
19
|
+
const steps = [
|
|
20
|
+
{ step: "install", done: 0, total: 3 },
|
|
21
|
+
{ step: "build", done: 1, total: 3 },
|
|
22
|
+
{ step: "test", done: 2, total: 3 },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
describe("a build started by a member", () => {
|
|
26
|
+
it.effect(
|
|
27
|
+
"reports each step to a caller that asks, over HTTP and MCP, then answers",
|
|
28
|
+
() =>
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const http = yield* ActionHttp.client(Http, {
|
|
31
|
+
transformClient: bearer("ada"),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const mcp = yield* ActionMcp.client([StartBuild], {
|
|
35
|
+
authentication: Login,
|
|
36
|
+
transformClient: bearer("ada"),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const seen: unknown[] = [];
|
|
40
|
+
|
|
41
|
+
const onProgress = (step: (typeof steps)[number]) =>
|
|
42
|
+
Effect.sync(() => seen.push(step));
|
|
43
|
+
|
|
44
|
+
// A caller asks for the steps by giving `onProgress`; without it, the call answers alone.
|
|
45
|
+
const answers = [
|
|
46
|
+
yield* http.startBuild({ pipeline: "api" }, { onProgress }),
|
|
47
|
+
yield* mcp.startBuild({ pipeline: "api" }, { onProgress }),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
expect(seen).toStrictEqual([...steps, ...steps]);
|
|
51
|
+
expect(answers).toStrictEqual([
|
|
52
|
+
{ id: "build-1", pipeline: "api", status: "passed" },
|
|
53
|
+
{ id: "build-2", pipeline: "api", status: "passed" },
|
|
54
|
+
]);
|
|
55
|
+
}).pipe(Effect.provide(served))
|
|
56
|
+
);
|
|
57
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { expect, it } from "@effect/vitest";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
4
|
+
import { Effect, Fiber, Stream } from "effect";
|
|
5
|
+
import { TestClock } from "effect/testing";
|
|
6
|
+
|
|
7
|
+
import { PipelineStatus } from "../../src/builds/Builds.js";
|
|
8
|
+
import { builds } from "../../src/builds/handlers.js";
|
|
9
|
+
import { statusEvents } from "../../src/builds/status.js";
|
|
10
|
+
import { BuildStore } from "../../src/builds/store.js";
|
|
11
|
+
import { as } from "../support/as.js";
|
|
12
|
+
|
|
13
|
+
it.effect(
|
|
14
|
+
"sends a pipeline's status as it is, then as each of its builds ends, and nothing for another's",
|
|
15
|
+
() =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const client = yield* Action.client([builds, statusEvents]);
|
|
18
|
+
|
|
19
|
+
const watching = yield* client
|
|
20
|
+
.watch(PipelineStatus, { pipeline: "web" })
|
|
21
|
+
.pipe(Stream.take(3), Stream.runCollect, as("grace"), Effect.forkChild);
|
|
22
|
+
|
|
23
|
+
yield* TestClock.adjust("0 millis");
|
|
24
|
+
yield* client
|
|
25
|
+
.finishBuild({ pipeline: "api", status: "failed" })
|
|
26
|
+
.pipe(as("ada"));
|
|
27
|
+
yield* TestClock.adjust("0 millis");
|
|
28
|
+
yield* client
|
|
29
|
+
.finishBuild({ pipeline: "web", status: "failed" })
|
|
30
|
+
.pipe(as("ada"));
|
|
31
|
+
yield* TestClock.adjust("0 millis");
|
|
32
|
+
yield* client
|
|
33
|
+
.finishBuild({ pipeline: "web", status: "passed" })
|
|
34
|
+
.pipe(as("ada"));
|
|
35
|
+
|
|
36
|
+
expect(yield* Fiber.join(watching)).toMatchObject([
|
|
37
|
+
{ _tag: "Occurrence", data: { status: null } },
|
|
38
|
+
{ _tag: "Occurrence", data: { status: "failed" } },
|
|
39
|
+
{ _tag: "Occurrence", data: { status: "passed" } },
|
|
40
|
+
]);
|
|
41
|
+
}).pipe(Effect.provide([BuildStore.layerMemory, Event.layerMemory]))
|
|
42
|
+
);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { NodeServices } from "@effect/platform-node";
|
|
2
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
3
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
4
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
5
|
+
import { ConfigProvider, Effect, Fiber, Layer, Schema } from "effect";
|
|
6
|
+
import { Command } from "effect/cli";
|
|
7
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
8
|
+
import { TestClock, TestConsole } from "effect/testing";
|
|
9
|
+
|
|
10
|
+
import { Http } from "../src/Api.js";
|
|
11
|
+
import { commands } from "../src/commands.js";
|
|
12
|
+
import { routes } from "../src/routes.js";
|
|
13
|
+
import { inMemory } from "./support/memory.js";
|
|
14
|
+
|
|
15
|
+
const served = Testing.layer(routes).pipe(Layer.provide(inMemory()));
|
|
16
|
+
|
|
17
|
+
// Effect's own runner, given arguments instead of the process's, as the member `token` names.
|
|
18
|
+
const run = (token: string, args: readonly string[]) =>
|
|
19
|
+
Command.runWith(commands, { version: "0" })(args).pipe(
|
|
20
|
+
Effect.provide(
|
|
21
|
+
ConfigProvider.layer(
|
|
22
|
+
ConfigProvider.fromEnvRecord({ BUILDS_TOKEN: token })
|
|
23
|
+
).pipe(Layer.provideMerge(NodeServices.layer))
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
describe("the commands", () => {
|
|
28
|
+
it.effect(
|
|
29
|
+
"print each build a member may see as a line of JSON, until stopped",
|
|
30
|
+
() =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
// A watch runs until it is stopped, as Ctrl-C stops it: here, by interrupting it.
|
|
33
|
+
const watching = yield* Effect.forkChild(
|
|
34
|
+
run("grace", ["watch", "build.finished"])
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
yield* TestClock.adjust("0 millis");
|
|
38
|
+
|
|
39
|
+
const ada = yield* ActionHttp.client(Http, {
|
|
40
|
+
transformClient: HttpClient.mapRequest(
|
|
41
|
+
HttpClientRequest.bearerToken("ada")
|
|
42
|
+
),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
yield* ada.finishBuild({ pipeline: "api", status: "passed" });
|
|
46
|
+
yield* ada.finishBuild({ pipeline: "web", status: "passed" });
|
|
47
|
+
yield* TestClock.adjust("5 seconds");
|
|
48
|
+
yield* Fiber.interrupt(watching);
|
|
49
|
+
|
|
50
|
+
// Each line carries its cursor: `--cursor` given it resumes after that build.
|
|
51
|
+
const lines = yield* Schema.decodeUnknownEffect(
|
|
52
|
+
Schema.Array(Schema.fromJsonString(Schema.Unknown))
|
|
53
|
+
)(yield* TestConsole.logLines);
|
|
54
|
+
|
|
55
|
+
expect(lines).toMatchObject([
|
|
56
|
+
{ _tag: "Occurrence", data: { id: "build-2", pipeline: "web" } },
|
|
57
|
+
]);
|
|
58
|
+
expect(JSON.stringify(lines)).toMatch(/"cursor":"[^"]+:2"/u);
|
|
59
|
+
}).pipe(Effect.provide(served))
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
it.effect(
|
|
63
|
+
"print each step of a build on stderr as it starts, and the build on stdout",
|
|
64
|
+
() =>
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
yield* run("ada", ["start-build", "--pipeline", "api"]);
|
|
67
|
+
|
|
68
|
+
// stdout holds the answer alone, so `builds start-build ... | jq` reads the build.
|
|
69
|
+
expect(yield* TestConsole.logLines).toStrictEqual([
|
|
70
|
+
JSON.stringify(
|
|
71
|
+
{ id: "build-1", pipeline: "api", status: "passed" },
|
|
72
|
+
null,
|
|
73
|
+
2
|
|
74
|
+
),
|
|
75
|
+
]);
|
|
76
|
+
expect(yield* TestConsole.errorLines).toStrictEqual([
|
|
77
|
+
'{"step":"install","done":0,"total":3}',
|
|
78
|
+
'{"step":"build","done":1,"total":3}',
|
|
79
|
+
'{"step":"test","done":2,"total":3}',
|
|
80
|
+
]);
|
|
81
|
+
}).pipe(Effect.provide(served))
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
it.effect(
|
|
85
|
+
"print each line of a build's log as a line of JSON, as it arrives",
|
|
86
|
+
() =>
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
yield* run("ada", [
|
|
89
|
+
"finish-build",
|
|
90
|
+
"--pipeline",
|
|
91
|
+
"web",
|
|
92
|
+
"--status",
|
|
93
|
+
"failed",
|
|
94
|
+
]);
|
|
95
|
+
yield* run("ada", ["tail-log", "--build-id", "build-1"]);
|
|
96
|
+
|
|
97
|
+
// A line per element, as JSON Lines: `builds tail-log ... | jq -r .line` prints the log.
|
|
98
|
+
expect((yield* TestConsole.logLines).slice(1)).toStrictEqual([
|
|
99
|
+
'{"line":"failed"}',
|
|
100
|
+
]);
|
|
101
|
+
}).pipe(Effect.provide(served))
|
|
102
|
+
);
|
|
103
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { expect, it } from "@effect/vitest";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
4
|
+
import { Effect, Fiber, Stream } from "effect";
|
|
5
|
+
import { TestClock } from "effect/testing";
|
|
6
|
+
|
|
7
|
+
import { Approvers } from "../../src/deploys/approvers.js";
|
|
8
|
+
import { DeployRequested } from "../../src/deploys/Deploys.js";
|
|
9
|
+
import { deployEvents } from "../../src/deploys/events.js";
|
|
10
|
+
import { deploys } from "../../src/deploys/handlers.js";
|
|
11
|
+
import { as } from "../support/as.js";
|
|
12
|
+
|
|
13
|
+
it.effect(
|
|
14
|
+
"delivers each deploy request to the subscribers of its environment, who stand by to approve it",
|
|
15
|
+
() =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const client = yield* Action.client([deploys, deployEvents]);
|
|
18
|
+
|
|
19
|
+
const production = yield* client
|
|
20
|
+
.watch(DeployRequested, { environment: "production" })
|
|
21
|
+
.pipe(Stream.take(1), Stream.runCollect, as("grace"), Effect.forkChild);
|
|
22
|
+
|
|
23
|
+
yield* TestClock.adjust("0 millis");
|
|
24
|
+
|
|
25
|
+
const staging = yield* client
|
|
26
|
+
.requestDeploy({ environment: "staging", version: "1.1.0" })
|
|
27
|
+
.pipe(as("ada"));
|
|
28
|
+
|
|
29
|
+
yield* client
|
|
30
|
+
.requestDeploy({ environment: "production", version: "1.0.9" })
|
|
31
|
+
.pipe(as("ada"));
|
|
32
|
+
|
|
33
|
+
expect(staging.approvers).toStrictEqual([]);
|
|
34
|
+
expect(yield* Fiber.join(production)).toMatchObject([
|
|
35
|
+
{
|
|
36
|
+
data: {
|
|
37
|
+
environment: "production",
|
|
38
|
+
version: "1.0.9",
|
|
39
|
+
requestedBy: "ada",
|
|
40
|
+
approvers: ["grace"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// The stream ended, and with it grace's standing by.
|
|
46
|
+
const later = yield* client
|
|
47
|
+
.requestDeploy({ environment: "production", version: "1.1.0" })
|
|
48
|
+
.pipe(as("ada"));
|
|
49
|
+
|
|
50
|
+
expect(later.approvers).toStrictEqual([]);
|
|
51
|
+
}).pipe(Effect.provide([Approvers.layerMemory, Event.layerMemory]))
|
|
52
|
+
);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
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 { Http } from "../src/Api.js";
|
|
10
|
+
import { BuildFinished } from "../src/builds/Builds.js";
|
|
11
|
+
import { DeployRequested } from "../src/deploys/Deploys.js";
|
|
12
|
+
import { routes } from "../src/routes.js";
|
|
13
|
+
import { inMemory } from "./support/memory.js";
|
|
14
|
+
|
|
15
|
+
const served = Testing.layer(routes).pipe(Layer.provide(inMemory()));
|
|
16
|
+
|
|
17
|
+
// One client of the binding per member: the token is the member, as in production.
|
|
18
|
+
const as = (token: string) =>
|
|
19
|
+
ActionHttp.client(Http, {
|
|
20
|
+
transformClient: HttpClient.mapRequest(
|
|
21
|
+
HttpClientRequest.bearerToken(token)
|
|
22
|
+
),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("the service, over HTTP", () => {
|
|
26
|
+
it.effect(
|
|
27
|
+
"streams each build a member may see, as SSE, as a call finishes it",
|
|
28
|
+
() =>
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const ada = yield* as("ada");
|
|
31
|
+
const grace = yield* as("grace");
|
|
32
|
+
|
|
33
|
+
// `watch` asks for `text/event-stream`, and the route holds the stream open.
|
|
34
|
+
const watched = yield* grace
|
|
35
|
+
.watch(BuildFinished)
|
|
36
|
+
.pipe(Stream.take(1), Stream.runCollect, Effect.forkChild);
|
|
37
|
+
|
|
38
|
+
// The watch starts from now: let it take its first position before builds finish.
|
|
39
|
+
yield* TestClock.adjust("0 millis");
|
|
40
|
+
yield* ada.finishBuild({ pipeline: "api", status: "passed" });
|
|
41
|
+
yield* ada.finishBuild({ pipeline: "web", status: "passed" });
|
|
42
|
+
|
|
43
|
+
expect(yield* Fiber.join(watched)).toMatchObject([
|
|
44
|
+
{ _tag: "Occurrence", data: { id: "build-2", pipeline: "web" } },
|
|
45
|
+
]);
|
|
46
|
+
}).pipe(Effect.provide(served))
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
it.effect(
|
|
50
|
+
"delivers deploy requests to a member that polls the route for JSON pages",
|
|
51
|
+
() =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const ada = yield* as("ada");
|
|
54
|
+
const grace = yield* as("grace");
|
|
55
|
+
|
|
56
|
+
// `mode: "poll"` asks for a page at a time, as a client that cannot hold a stream does.
|
|
57
|
+
const polled = yield* grace
|
|
58
|
+
.watch(
|
|
59
|
+
DeployRequested,
|
|
60
|
+
{ environment: "production" },
|
|
61
|
+
{ mode: "poll" }
|
|
62
|
+
)
|
|
63
|
+
.pipe(Stream.take(1), Stream.runCollect, Effect.forkChild);
|
|
64
|
+
|
|
65
|
+
yield* TestClock.adjust("0 millis");
|
|
66
|
+
|
|
67
|
+
const deploy = yield* ada.requestDeploy({
|
|
68
|
+
environment: "production",
|
|
69
|
+
version: "1.0.9",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
yield* TestClock.adjust("5 seconds");
|
|
73
|
+
|
|
74
|
+
expect(deploy.approvers).toStrictEqual(["grace"]);
|
|
75
|
+
expect(yield* Fiber.join(polled)).toMatchObject([
|
|
76
|
+
{
|
|
77
|
+
_tag: "Occurrence",
|
|
78
|
+
data: { version: "1.0.9", approvers: ["grace"] },
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
}).pipe(Effect.provide(served))
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
it.effect(
|
|
85
|
+
"refuses a watch without a member's token, as the route's 401",
|
|
86
|
+
() =>
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
const stranger = yield* as("mallory");
|
|
89
|
+
|
|
90
|
+
expect(
|
|
91
|
+
yield* Effect.flip(Stream.runDrain(stranger.watch(BuildFinished)))
|
|
92
|
+
).toBeInstanceOf(Action.Unauthenticated);
|
|
93
|
+
}).pipe(Effect.provide(served))
|
|
94
|
+
);
|
|
95
|
+
});
|