ankka 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +30 -0
  2. package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +350 -0
  3. package/dist/_proto/ankka/protocol/v1/agent_pb.js +90 -0
  4. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +254 -0
  5. package/dist/_proto/ankka/protocol/v1/client_pb.js +47 -0
  6. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +105 -0
  7. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +28 -0
  8. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +598 -0
  9. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +175 -0
  10. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +224 -0
  11. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +44 -0
  12. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +212 -0
  13. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +47 -0
  14. package/dist/_proto/ankka/protocol/v1/key_value_pb.d.ts +154 -0
  15. package/dist/_proto/ankka/protocol/v1/key_value_pb.js +36 -0
  16. package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +276 -0
  17. package/dist/_proto/ankka/protocol/v1/payload_pb.js +110 -0
  18. package/dist/_proto/ankka/protocol/v1/timed_action_pb.d.ts +75 -0
  19. package/dist/_proto/ankka/protocol/v1/timed_action_pb.js +23 -0
  20. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +93 -0
  21. package/dist/_proto/ankka/protocol/v1/view_pb.js +23 -0
  22. package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +298 -0
  23. package/dist/_proto/ankka/protocol/v1/workflow_pb.js +67 -0
  24. package/dist/agent.d.ts +27 -0
  25. package/dist/agent.js +36 -0
  26. package/dist/client.d.ts +92 -0
  27. package/dist/client.js +239 -0
  28. package/dist/codec.d.ts +44 -0
  29. package/dist/codec.js +215 -0
  30. package/dist/consumer.d.ts +31 -0
  31. package/dist/consumer.js +32 -0
  32. package/dist/context.d.ts +61 -0
  33. package/dist/context.js +75 -0
  34. package/dist/effects/agent.d.ts +37 -0
  35. package/dist/effects/agent.js +48 -0
  36. package/dist/effects/common.d.ts +68 -0
  37. package/dist/effects/common.js +55 -0
  38. package/dist/effects/eventSourced.d.ts +50 -0
  39. package/dist/effects/eventSourced.js +66 -0
  40. package/dist/effects/keyValue.d.ts +36 -0
  41. package/dist/effects/keyValue.js +47 -0
  42. package/dist/effects/stateless.d.ts +40 -0
  43. package/dist/effects/stateless.js +35 -0
  44. package/dist/effects/workflow.d.ts +99 -0
  45. package/dist/effects/workflow.js +83 -0
  46. package/dist/endpoint.d.ts +21 -0
  47. package/dist/endpoint.js +29 -0
  48. package/dist/eventSourcedEntity.d.ts +40 -0
  49. package/dist/eventSourcedEntity.js +66 -0
  50. package/dist/handlers.d.ts +55 -0
  51. package/dist/handlers.js +64 -0
  52. package/dist/index.d.ts +29 -0
  53. package/dist/index.js +39 -0
  54. package/dist/json.d.ts +21 -0
  55. package/dist/json.js +362 -0
  56. package/dist/keyValueEntity.d.ts +29 -0
  57. package/dist/keyValueEntity.js +52 -0
  58. package/dist/kinds.d.ts +8 -0
  59. package/dist/kinds.js +41 -0
  60. package/dist/materialise.d.ts +33 -0
  61. package/dist/materialise.js +55 -0
  62. package/dist/routes.d.ts +71 -0
  63. package/dist/routes.js +79 -0
  64. package/dist/schema.d.ts +112 -0
  65. package/dist/schema.js +200 -0
  66. package/dist/server/agent.d.ts +14 -0
  67. package/dist/server/agent.js +113 -0
  68. package/dist/server/discovery.d.ts +5 -0
  69. package/dist/server/discovery.js +21 -0
  70. package/dist/server/eventSourced.d.ts +12 -0
  71. package/dist/server/eventSourced.js +168 -0
  72. package/dist/server/http.d.ts +10 -0
  73. package/dist/server/http.js +153 -0
  74. package/dist/server/keyValue.d.ts +5 -0
  75. package/dist/server/keyValue.js +122 -0
  76. package/dist/server/payloads.d.ts +6 -0
  77. package/dist/server/payloads.js +11 -0
  78. package/dist/server/queue.d.ts +10 -0
  79. package/dist/server/queue.js +67 -0
  80. package/dist/server/server.d.ts +34 -0
  81. package/dist/server/server.js +101 -0
  82. package/dist/server/stateless.d.ts +9 -0
  83. package/dist/server/stateless.js +108 -0
  84. package/dist/server/workflow.d.ts +5 -0
  85. package/dist/server/workflow.js +244 -0
  86. package/dist/service.d.ts +147 -0
  87. package/dist/service.js +384 -0
  88. package/dist/spec.d.ts +7 -0
  89. package/dist/spec.js +106 -0
  90. package/dist/testkit/index.d.ts +3 -0
  91. package/dist/testkit/index.js +6 -0
  92. package/dist/testkit/integration.d.ts +60 -0
  93. package/dist/testkit/integration.js +284 -0
  94. package/dist/testkit/kinds.d.ts +133 -0
  95. package/dist/testkit/kinds.js +398 -0
  96. package/dist/testkit/unit.d.ts +54 -0
  97. package/dist/testkit/unit.js +224 -0
  98. package/dist/time.d.ts +73 -0
  99. package/dist/time.js +275 -0
  100. package/dist/timedAction.d.ts +20 -0
  101. package/dist/timedAction.js +28 -0
  102. package/dist/version.d.ts +1 -0
  103. package/dist/version.js +2 -0
  104. package/dist/view.d.ts +37 -0
  105. package/dist/view.js +52 -0
  106. package/dist/workflow.d.ts +33 -0
  107. package/dist/workflow.js +61 -0
  108. package/package.json +75 -0
@@ -0,0 +1,101 @@
1
+ // The process side of the protocol: an HTTP/2 server on loopback carrying Connect's adapter, speaking
2
+ // gRPC to the sidecar. HTTP/2 because the gRPC protocol and the per-instance bidirectional streams
3
+ // need it; loopback because the protocol is never reachable from outside the pod, and `0.0.0.0` only for
4
+ // the integration testkit, whose sidecar reaches this process from a container.
5
+ import { createServer } from "node:http2";
6
+ import { connectNodeAdapter } from "@connectrpc/connect-node";
7
+ import { renderSpec } from "../spec.js";
8
+ import { discoveryRoutes } from "./discovery.js";
9
+ import { eventSourcedRoutes } from "./eventSourced.js";
10
+ import { keyValueRoutes } from "./keyValue.js";
11
+ import { workflowRoutes } from "./workflow.js";
12
+ import { statelessRoutes } from "./stateless.js";
13
+ import { agentRoutes } from "./agent.js";
14
+ import { httpRoutes } from "./http.js";
15
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "0.0.0.0", "::"]);
16
+ export class Server {
17
+ #context;
18
+ #options;
19
+ #shutdown = new AbortController();
20
+ #sessions = new Set();
21
+ #http2;
22
+ #address;
23
+ #resolveClosed;
24
+ closed = new Promise((resolve) => {
25
+ this.#resolveClosed = resolve;
26
+ });
27
+ constructor(registry, client, options = {}) {
28
+ this.#context = { registry, client, log: options.log ?? ((m) => console.error(m)) };
29
+ this.#options = options;
30
+ }
31
+ /** Binds and starts serving. Resolves with the bound address, which matters when `port` was 0. */
32
+ async start() {
33
+ if (this.#http2)
34
+ throw new Error("the server is already started");
35
+ const host = this.#options.host ?? "127.0.0.1";
36
+ if (!LOOPBACK.has(host)) {
37
+ throw new Error(`ankka serves the protocol on loopback only (or 0.0.0.0 for the testkit); refusing to bind ${host}`);
38
+ }
39
+ const port = this.#options.port ?? Number(process.env.ANKKA_PROCESS_PORT ?? 9010);
40
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
41
+ throw new Error(`not a port: ${String(port)}`);
42
+ const handler = connectNodeAdapter({
43
+ routes: (router) => {
44
+ discoveryRoutes(router, () => renderSpec(this.#context.registry), this.#context.log);
45
+ eventSourcedRoutes(router, this.#context);
46
+ keyValueRoutes(router, this.#context);
47
+ workflowRoutes(router, this.#context);
48
+ statelessRoutes(router, this.#context);
49
+ agentRoutes(router, this.#context);
50
+ httpRoutes(router, this.#context);
51
+ },
52
+ shutdownSignal: this.#shutdown.signal,
53
+ readMaxBytes: this.#options.readMaxBytes ?? 64 * 1024 * 1024,
54
+ grpcWeb: false,
55
+ connect: false,
56
+ });
57
+ const server = createServer({ maxSessionMemory: 64 }, handler);
58
+ this.#http2 = server;
59
+ // `server.close()` waits for every session to end, and a client keeps an idle session open for
60
+ // minutes; `stop` closes the sessions it knows about so a stop is a stop.
61
+ server.on("session", (session) => {
62
+ this.#sessions.add(session);
63
+ session.once("close", () => this.#sessions.delete(session));
64
+ });
65
+ await new Promise((resolve, reject) => {
66
+ server.once("error", reject);
67
+ server.listen(port, host, () => {
68
+ server.off("error", reject);
69
+ resolve();
70
+ });
71
+ });
72
+ const info = server.address();
73
+ this.#address = { host, port: info.port };
74
+ server.on("close", () => this.#resolveClosed());
75
+ return this.#address;
76
+ }
77
+ /** The bound address; throws before `start`. */
78
+ get address() {
79
+ if (!this.#address)
80
+ throw new Error("the server is not started");
81
+ return this.#address;
82
+ }
83
+ /** Aborts every stream, closes every session and the listener, and resolves `closed`. */
84
+ async stop() {
85
+ const server = this.#http2;
86
+ if (!server)
87
+ return;
88
+ this.#shutdown.abort();
89
+ const closing = new Promise((resolve) => server.close(() => resolve()));
90
+ for (const session of this.#sessions)
91
+ session.close();
92
+ const grace = setTimeout(() => {
93
+ for (const session of this.#sessions)
94
+ session.destroy();
95
+ }, 250);
96
+ await closing;
97
+ clearTimeout(grace);
98
+ this.#http2 = undefined;
99
+ this.#resolveClosed();
100
+ }
101
+ }
@@ -0,0 +1,9 @@
1
+ import { type ConnectRouter } from "@connectrpc/connect";
2
+ import { type ViewEffect as ProtoViewEffect, type ViewRequest } from "../_proto/ankka/protocol/v1/view_pb.ts";
3
+ import { type ConsumerEffect as ProtoConsumerEffect, type ConsumerRequest } from "../_proto/ankka/protocol/v1/consumer_pb.ts";
4
+ import { type TimedActionEffect as ProtoTimedActionEffect, type TimedActionRequest } from "../_proto/ankka/protocol/v1/timed_action_pb.ts";
5
+ import type { ServerContext } from "./server.ts";
6
+ export declare function handleView(req: ViewRequest, ctx: ServerContext): Promise<ProtoViewEffect>;
7
+ export declare function handleConsumer(req: ConsumerRequest, ctx: ServerContext): Promise<ProtoConsumerEffect>;
8
+ export declare function handleTimedAction(req: TimedActionRequest, ctx: ServerContext): Promise<ProtoTimedActionEffect>;
9
+ export declare function statelessRoutes(router: ConnectRouter, ctx: ServerContext): void;
@@ -0,0 +1,108 @@
1
+ // The stateless conversations: a view's change, a consumer's message, a timed action's call. A new
2
+ // instance per request; nothing is held between requests.
3
+ import { Code, ConnectError } from "@connectrpc/connect";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import { View as ViewService, ViewEffectSchema } from "../_proto/ankka/protocol/v1/view_pb.js";
6
+ import { Consumer as ConsumerService, ConsumerEffectSchema } from "../_proto/ankka/protocol/v1/consumer_pb.js";
7
+ import { TimedAction as TimedActionService, TimedActionEffectSchema } from "../_proto/ankka/protocol/v1/timed_action_pb.js";
8
+ import { codecFor } from "../codec.js";
9
+ import { metadataFromProto, metadataToProto } from "../context.js";
10
+ import { ErrorCode } from "../effects/common.js";
11
+ import { errorCodeToProto } from "../kinds.js";
12
+ import { decodePayload, encodePayload } from "./payloads.js";
13
+ function messageOf(e) {
14
+ return e instanceof Error ? `${e.name}: ${e.message}` : String(e);
15
+ }
16
+ export async function handleView(req, ctx) {
17
+ const registered = ctx.registry.of("view", req.componentId);
18
+ if (!registered)
19
+ throw new ConnectError(`no view ${JSON.stringify(req.componentId)} is registered`, Code.NotFound);
20
+ const metadata = metadataFromProto(req.metadata);
21
+ const view = new registered.cls();
22
+ try {
23
+ const row = req.row ? decodePayload(registered.rowCodec, req.row) : null;
24
+ view._bind(row, metadata, ctx.client.withMetadata(metadata));
25
+ const effect = req.deleted ? await view.onDelete() : await view.onChange(decodePayload(registered.eventCodec, req.event));
26
+ switch (effect.kind) {
27
+ case "update-row":
28
+ return create(ViewEffectSchema, { effect: { case: "updateRow", value: encodePayload(registered.rowCodec, effect.row) } });
29
+ case "delete-row":
30
+ return create(ViewEffectSchema, { effect: { case: "deleteRow", value: {} } });
31
+ case "ignore":
32
+ return create(ViewEffectSchema, { effect: { case: "ignore", value: {} } });
33
+ default:
34
+ throw new TypeError(`${registered.id}.onChange returned something that is not a view effect`);
35
+ }
36
+ }
37
+ catch (e) {
38
+ if (e instanceof ConnectError)
39
+ throw e;
40
+ ctx.log(`ankka: view ${registered.id} on ${metadata["ce-subject"] ?? "?"} threw: ${messageOf(e)}`);
41
+ throw new ConnectError(messageOf(e), Code.Internal);
42
+ }
43
+ }
44
+ export async function handleConsumer(req, ctx) {
45
+ const registered = ctx.registry.of("consumer", req.componentId);
46
+ if (!registered)
47
+ throw new ConnectError(`no consumer ${JSON.stringify(req.componentId)} is registered`, Code.NotFound);
48
+ const metadata = metadataFromProto(req.metadata);
49
+ const consumer = new registered.cls();
50
+ try {
51
+ consumer._bind(metadata, ctx.client.withMetadata(metadata));
52
+ const effect = req.deleted ? await consumer.onDelete() : await consumer.onMessage(decodePayload(registered.messageCodec, req.message));
53
+ switch (effect.kind) {
54
+ case "produce": {
55
+ if (!registered.outCodec)
56
+ throw new Error(`${registered.id} produced a message but declares no out shape`);
57
+ return create(ConsumerEffectSchema, { effect: { case: "produce", value: { payload: encodePayload(registered.outCodec, effect.payload), metadata: metadataToProto(effect.metadata) } } });
58
+ }
59
+ case "done":
60
+ return create(ConsumerEffectSchema, { effect: { case: "done", value: {} } });
61
+ case "ignore":
62
+ return create(ConsumerEffectSchema, { effect: { case: "ignore", value: {} } });
63
+ default:
64
+ throw new TypeError(`${registered.id}.onMessage returned something that is not a consumer effect`);
65
+ }
66
+ }
67
+ catch (e) {
68
+ if (e instanceof ConnectError)
69
+ throw e;
70
+ ctx.log(`ankka: consumer ${registered.id} on ${metadata["ce-subject"] ?? "?"} threw: ${messageOf(e)}`);
71
+ throw new ConnectError(messageOf(e), Code.Internal);
72
+ }
73
+ }
74
+ export async function handleTimedAction(req, ctx) {
75
+ const fail = (message, code = ErrorCode.Internal) => create(TimedActionEffectSchema, { effect: { case: "fail", value: { message, code: errorCodeToProto(code) } } });
76
+ const registered = ctx.registry.of("timed-action", req.componentId);
77
+ if (!registered)
78
+ return fail(`no timed action ${JSON.stringify(req.componentId)} is registered`, ErrorCode.NotFound);
79
+ const action = registered.actions.get(req.name);
80
+ if (!action)
81
+ return fail(`no action ${JSON.stringify(req.name)} on ${registered.id}`, ErrorCode.NotFound);
82
+ const metadata = metadataFromProto(req.metadata);
83
+ const instance = new registered.cls();
84
+ try {
85
+ const input = action.input ? decodePayload(codecFor(action.input), req.payload) : undefined;
86
+ instance._bind(metadata, ctx.client.withMetadata(metadata));
87
+ const effect = await action.run(instance, input);
88
+ switch (effect.kind) {
89
+ case "done":
90
+ return create(TimedActionEffectSchema, { effect: { case: "done", value: {} } });
91
+ case "fail": {
92
+ const f = effect;
93
+ return fail(f.error.message, f.error.code);
94
+ }
95
+ default:
96
+ return fail(`${registered.id}/${req.name} returned something that is not a timed action effect`);
97
+ }
98
+ }
99
+ catch (e) {
100
+ ctx.log(`ankka: timed action ${registered.id}/${req.name} threw: ${messageOf(e)}`);
101
+ return fail(messageOf(e));
102
+ }
103
+ }
104
+ export function statelessRoutes(router, ctx) {
105
+ router.service(ViewService, { handle: (req) => handleView(req, ctx) });
106
+ router.service(ConsumerService, { handle: (req) => handleConsumer(req, ctx) });
107
+ router.service(TimedActionService, { invoke: (req) => handleTimedAction(req, ctx) });
108
+ }
@@ -0,0 +1,5 @@
1
+ import type { ConnectRouter } from "@connectrpc/connect";
2
+ import { type WorkflowIn, type WorkflowOut } from "../_proto/ankka/protocol/v1/workflow_pb.ts";
3
+ import type { ServerContext } from "./server.ts";
4
+ export declare function handleWorkflow(requests: AsyncIterable<WorkflowIn>, ctx: ServerContext): AsyncIterable<WorkflowOut>;
5
+ export declare function workflowRoutes(router: ConnectRouter, ctx: ServerContext): void;
@@ -0,0 +1,244 @@
1
+ // The workflow conversation: one bidirectional stream per loaded instance, carrying commands *and*
2
+ // steps. The engine keeps answering commands while a step runs, so this side holds two slots: the
3
+ // stream's own instance answers commands from the state before the step, and each step runs on a fresh
4
+ // instance so the two never share a context. Replies leave through one queue, so a command is never
5
+ // stuck behind a step.
6
+ import { create } from "@bufbuild/protobuf";
7
+ import { Workflow as WorkflowService, WorkflowOutSchema, } from "../_proto/ankka/protocol/v1/workflow_pb.js";
8
+ import { binaryCodecs, codecFor } from "../codec.js";
9
+ import { commandContext, metadataFromProto } from "../context.js";
10
+ import { ErrorCode } from "../effects/common.js";
11
+ import { errorCodeToProto } from "../kinds.js";
12
+ import { materialiseStep, materialiseWorkflowCommand } from "../materialise.js";
13
+ import { decodePayload, encodePayload } from "./payloads.js";
14
+ import { AsyncQueue } from "./queue.js";
15
+ function failure(commandId, error) {
16
+ return create(WorkflowOutSchema, { message: { case: "failure", value: { commandId, error: { message: error.message, code: errorCodeToProto(error.code) } } } });
17
+ }
18
+ function messageOf(e) {
19
+ return e instanceof Error ? `${e.name}: ${e.message}` : String(e);
20
+ }
21
+ /** A step reference as the protocol carries it; the input is encoded with the *target* step's declared shape. */
22
+ function stepRefInit(registered, ref) {
23
+ const target = registered.steps.get(ref.step);
24
+ if (!target)
25
+ throw new Error(`transition to ${JSON.stringify(ref.step)}, which is not a declared step`);
26
+ if (ref.input === undefined || ref.input === null)
27
+ return { step: ref.step };
28
+ if (!target.input)
29
+ throw new Error(`step ${JSON.stringify(ref.step)} takes no input, but one was given`);
30
+ return { step: ref.step, input: encodePayload(codecFor(target.input), ref.input) };
31
+ }
32
+ class WorkflowStream {
33
+ registered;
34
+ instance;
35
+ entityId;
36
+ state;
37
+ running;
38
+ out = new AsyncQueue();
39
+ constructor(registered, instance, entityId, state) {
40
+ this.registered = registered;
41
+ this.instance = instance;
42
+ this.entityId = entityId;
43
+ this.state = state;
44
+ }
45
+ async command(cmd, ctx) {
46
+ const { registered, instance } = this;
47
+ const handler = registered.handlers.get(cmd.name);
48
+ if (!handler)
49
+ return failure(cmd.id, { message: `no handler ${JSON.stringify(cmd.name)} on ${registered.id}`, code: ErrorCode.NotFound });
50
+ let input;
51
+ try {
52
+ input = handler.input ? decodePayload(codecFor(handler.input), cmd.payload) : undefined;
53
+ }
54
+ catch (e) {
55
+ return failure(cmd.id, { message: `${registered.id}/${cmd.name}: the input could not be decoded: ${messageOf(e)}`, code: ErrorCode.BadRequest });
56
+ }
57
+ const metadata = metadataFromProto(cmd.metadata);
58
+ let effect;
59
+ instance._bindCommand(this.state, commandContext(registered.id, this.entityId, 0n, metadata), ctx.client.withMetadata(metadata));
60
+ try {
61
+ effect = (await handler.run(instance, input));
62
+ }
63
+ catch (e) {
64
+ ctx.log(`ankka: ${registered.id}/${this.entityId}/${cmd.name} threw: ${messageOf(e)}`);
65
+ return failure(cmd.id, { message: messageOf(e), code: ErrorCode.Internal });
66
+ }
67
+ finally {
68
+ instance._unbindCommand();
69
+ }
70
+ if (handler.readOnly && effect?.kind !== "read-only") {
71
+ return failure(cmd.id, { message: `${registered.id}/${cmd.name} is a query and returned a changing effect`, code: ErrorCode.Internal });
72
+ }
73
+ try {
74
+ const m = materialiseWorkflowCommand(effect, this.state);
75
+ const replyCodec = handler.reply ? codecFor(handler.reply) : binaryCodecs.done;
76
+ const outcome = m.error
77
+ ? { outcome: { case: "error", value: { message: m.error.message, code: errorCodeToProto(m.error.code) } } }
78
+ : m.noReply
79
+ ? { outcome: { case: "noReply", value: {} } }
80
+ : { outcome: { case: "reply", value: { payload: encodePayload(replyCodec, m.reply), metadata: { entries: [] } } } };
81
+ const out = create(WorkflowOutSchema, {
82
+ message: {
83
+ case: "reply",
84
+ value: {
85
+ commandId: cmd.id,
86
+ newState: m.changed && !m.error ? encodePayload(registered.stateCodec, m.newState) : undefined,
87
+ transition: m.transition && !m.error ? stepRefInit(registered, m.transition) : undefined,
88
+ outcome,
89
+ },
90
+ },
91
+ });
92
+ if (!m.error)
93
+ this.state = m.newState;
94
+ return out;
95
+ }
96
+ catch (e) {
97
+ ctx.log(`ankka: ${registered.id}/${this.entityId}/${cmd.name}: the effect could not be applied: ${messageOf(e)}`);
98
+ return failure(cmd.id, { message: messageOf(e), code: ErrorCode.Internal });
99
+ }
100
+ }
101
+ /** Runs a step on a fresh instance, as the sidecar's engine does: commands keep arriving on the stream's instance meanwhile. */
102
+ async step(run, ctx) {
103
+ const { registered } = this;
104
+ const step = registered.steps.get(run.step);
105
+ if (!step)
106
+ return failure(run.id, { message: `no step ${JSON.stringify(run.step)} on ${registered.id}`, code: ErrorCode.NotFound });
107
+ const fresh = new registered.cls();
108
+ fresh._bindInstance(this.entityId);
109
+ let input;
110
+ try {
111
+ input = step.input ? decodePayload(codecFor(step.input), run.input) : undefined;
112
+ }
113
+ catch (e) {
114
+ return failure(run.id, { message: `${registered.id}/${run.step}: the input could not be decoded: ${messageOf(e)}`, code: ErrorCode.BadRequest });
115
+ }
116
+ let effect;
117
+ fresh._bindCommand(this.state, commandContext(registered.id, this.entityId, 0n, {}), ctx.client);
118
+ try {
119
+ effect = (await step.run(fresh, input));
120
+ }
121
+ catch (e) {
122
+ // A thrown step is a *fault*: the engine applies the declared recovery. A `thenFail` is a decision.
123
+ ctx.log(`ankka: ${registered.id}/${this.entityId} step ${run.step} threw: ${messageOf(e)}`);
124
+ return failure(run.id, { message: messageOf(e), code: ErrorCode.Internal });
125
+ }
126
+ finally {
127
+ fresh._unbindCommand();
128
+ }
129
+ try {
130
+ const m = materialiseStep(effect, this.state);
131
+ let next;
132
+ switch (m.next.kind) {
133
+ case "transition":
134
+ next = { outcome: { case: "transitionTo", value: stepRefInit(registered, m.next.ref) } };
135
+ break;
136
+ case "pause":
137
+ next = {
138
+ outcome: {
139
+ case: "pause",
140
+ value: {
141
+ ...(m.next.after ? { afterMillis: BigInt(m.next.after.toMillis()) } : {}),
142
+ ...(m.next.onTimeout ? { onTimeout: stepRefInit(registered, m.next.onTimeout) } : {}),
143
+ },
144
+ },
145
+ };
146
+ break;
147
+ case "end":
148
+ next = { outcome: { case: "end", value: {} } };
149
+ break;
150
+ case "fail":
151
+ next = { outcome: { case: "fail", value: { message: m.next.error.message, code: errorCodeToProto(m.next.error.code) } } };
152
+ break;
153
+ }
154
+ const out = create(WorkflowOutSchema, {
155
+ message: { case: "stepReply", value: { commandId: run.id, newState: m.changed ? encodePayload(registered.stateCodec, m.newState) : undefined, next } },
156
+ });
157
+ this.state = m.newState;
158
+ return out;
159
+ }
160
+ catch (e) {
161
+ ctx.log(`ankka: ${registered.id}/${this.entityId} step ${run.step}: the effect could not be applied: ${messageOf(e)}`);
162
+ return failure(run.id, { message: messageOf(e), code: ErrorCode.Internal });
163
+ }
164
+ }
165
+ }
166
+ export async function* handleWorkflow(requests, ctx) {
167
+ const out = new AsyncQueue();
168
+ let stream;
169
+ const read = async () => {
170
+ try {
171
+ for await (const m of requests) {
172
+ switch (m.message.case) {
173
+ case "init": {
174
+ const init = m.message.value;
175
+ const found = ctx.registry.of("workflow", init.componentId);
176
+ if (!found) {
177
+ out.push(failure(0n, { message: `no workflow ${JSON.stringify(init.componentId)} is registered`, code: ErrorCode.NotFound }));
178
+ return;
179
+ }
180
+ try {
181
+ const instance = new found.cls();
182
+ instance._bindInstance(init.entityId);
183
+ const state = init.state ? decodePayload(found.stateCodec, init.state) : instance.emptyState();
184
+ stream = new WorkflowStream(found, instance, init.entityId, state);
185
+ }
186
+ catch (e) {
187
+ ctx.log(`ankka: ${init.componentId}/${init.entityId}: recovery failed: ${messageOf(e)}`);
188
+ out.push(failure(0n, { message: `recovery failed: ${messageOf(e)}`, code: ErrorCode.Internal }));
189
+ return;
190
+ }
191
+ break;
192
+ }
193
+ case "command": {
194
+ if (!stream) {
195
+ out.push(failure(m.message.value.id, { message: "a command arrived before Init", code: ErrorCode.Internal }));
196
+ return;
197
+ }
198
+ out.push(await stream.command(m.message.value, ctx));
199
+ break;
200
+ }
201
+ case "runStep": {
202
+ if (!stream) {
203
+ out.push(failure(m.message.value.id, { message: "a step arrived before Init", code: ErrorCode.Internal }));
204
+ return;
205
+ }
206
+ if (stream.running) {
207
+ out.push(failure(m.message.value.id, { message: "a step is already running", code: ErrorCode.Internal }));
208
+ break;
209
+ }
210
+ const s = stream;
211
+ const run = m.message.value;
212
+ s.running = s
213
+ .step(run, ctx)
214
+ .then((reply) => out.push(reply))
215
+ .finally(() => {
216
+ s.running = undefined;
217
+ });
218
+ break;
219
+ }
220
+ default:
221
+ break;
222
+ }
223
+ }
224
+ }
225
+ catch (e) {
226
+ out.fail(e);
227
+ return;
228
+ }
229
+ // The sidecar closed its side: a step still running has nowhere to answer; its result is dropped with the state.
230
+ out.close();
231
+ };
232
+ void read();
233
+ try {
234
+ yield* out;
235
+ }
236
+ finally {
237
+ out.close();
238
+ }
239
+ }
240
+ export function workflowRoutes(router, ctx) {
241
+ router.service(WorkflowService, {
242
+ handle: (requests) => handleWorkflow(requests, ctx),
243
+ });
244
+ }
@@ -0,0 +1,147 @@
1
+ import { type Codec } from "./codec.ts";
2
+ import { EventSourcedEntity, type EventSourcedEntityClass } from "./eventSourcedEntity.ts";
3
+ import { KeyValueEntity, type KeyValueEntityClass } from "./keyValueEntity.ts";
4
+ import { Workflow, type WorkflowClass } from "./workflow.ts";
5
+ import { View, type ViewClass } from "./view.ts";
6
+ import { Consumer, type ConsumerClass } from "./consumer.ts";
7
+ import { TimedAction, type TimedActionClass } from "./timedAction.ts";
8
+ import { Agent, type AgentClass } from "./agent.ts";
9
+ import { Endpoint, type EndpointClass } from "./endpoint.ts";
10
+ import type { GuardrailRef, HandlerRef, ToolRef } from "./handlers.ts";
11
+ import type { RouteRef, Acl } from "./routes.ts";
12
+ import { ComponentClient } from "./client.ts";
13
+ import type { ComponentKind } from "./kinds.ts";
14
+ import type { WorkflowSettings } from "./effects/workflow.ts";
15
+ import { Server, type ServerOptions } from "./server/server.ts";
16
+ import type { Spec } from "./_proto/ankka/protocol/v1/discovery_pb.ts";
17
+ export interface RegisteredEventSourced {
18
+ readonly kind: "event-sourced";
19
+ readonly id: string;
20
+ readonly cls: EventSourcedEntityClass<any, any, any>;
21
+ readonly stateCodec: Codec<any>;
22
+ readonly eventCodec: Codec<any>;
23
+ readonly handlers: ReadonlyMap<string, HandlerRef<any, any, any, any>>;
24
+ readonly snapshotEvery: number;
25
+ }
26
+ export interface RegisteredKeyValue {
27
+ readonly kind: "key-value";
28
+ readonly id: string;
29
+ readonly cls: KeyValueEntityClass<any, any>;
30
+ readonly stateCodec: Codec<any>;
31
+ readonly handlers: ReadonlyMap<string, HandlerRef<any, any, any, any>>;
32
+ }
33
+ export interface RegisteredWorkflow {
34
+ readonly kind: "workflow";
35
+ readonly id: string;
36
+ readonly cls: WorkflowClass<any, any>;
37
+ readonly stateCodec: Codec<any>;
38
+ readonly handlers: ReadonlyMap<string, HandlerRef<any, any, any, any>>;
39
+ readonly steps: ReadonlyMap<string, HandlerRef<any, any, any, any>>;
40
+ readonly settings: WorkflowSettings | undefined;
41
+ }
42
+ /** Where a view or consumer reads from: a component, by kind and id, or a topic. */
43
+ export type Source = {
44
+ readonly component: {
45
+ readonly kind: ComponentKind;
46
+ readonly id: string;
47
+ };
48
+ } | {
49
+ readonly topic: string;
50
+ };
51
+ export interface RegisteredView {
52
+ readonly kind: "view";
53
+ readonly id: string;
54
+ readonly cls: ViewClass<any, any, any>;
55
+ readonly source: Source;
56
+ readonly eventCodec: Codec<any>;
57
+ readonly rowCodec: Codec<any>;
58
+ readonly queries: readonly string[];
59
+ }
60
+ export interface RegisteredConsumer {
61
+ readonly kind: "consumer";
62
+ readonly id: string;
63
+ readonly cls: ConsumerClass<any, any, any>;
64
+ readonly source: Source;
65
+ readonly messageCodec: Codec<any>;
66
+ readonly outCodec: Codec<any> | undefined;
67
+ readonly producesTo: string | undefined;
68
+ }
69
+ export interface RegisteredTimedAction {
70
+ readonly kind: "timed-action";
71
+ readonly id: string;
72
+ readonly cls: TimedActionClass<any>;
73
+ readonly actions: ReadonlyMap<string, HandlerRef<any, any, any, any>>;
74
+ }
75
+ export interface RegisteredAgent {
76
+ readonly kind: "agent";
77
+ readonly id: string;
78
+ readonly cls: AgentClass<any>;
79
+ readonly handlers: ReadonlyMap<string, HandlerRef<any, any, any, any>>;
80
+ readonly tools: ReadonlyMap<string, ToolRef<any, any>>;
81
+ readonly guardrails: ReadonlyMap<string, GuardrailRef>;
82
+ readonly role: string;
83
+ readonly maxToolCallSteps: number;
84
+ }
85
+ export type RegisteredComponent = RegisteredEventSourced | RegisteredKeyValue | RegisteredWorkflow | RegisteredView | RegisteredConsumer | RegisteredTimedAction | RegisteredAgent;
86
+ export interface RegisteredEndpoint {
87
+ readonly id: string;
88
+ readonly cls: EndpointClass<any>;
89
+ readonly prefix: string;
90
+ readonly acl: Acl;
91
+ /** Route id (the property name) → route. */
92
+ readonly routes: ReadonlyMap<string, RouteRef<any, any, any, any>>;
93
+ }
94
+ /** Everything the service hosts, validated. */
95
+ export declare class Registry {
96
+ readonly components: ReadonlyMap<string, RegisteredComponent>;
97
+ readonly endpoints: ReadonlyMap<string, RegisteredEndpoint>;
98
+ constructor(components: ReadonlyMap<string, RegisteredComponent>, endpoints: ReadonlyMap<string, RegisteredEndpoint>);
99
+ component(id: string): RegisteredComponent | undefined;
100
+ /** The component `id` when it is of `kind`. */
101
+ of<K extends RegisteredComponent["kind"]>(kind: K, id: string): Extract<RegisteredComponent, {
102
+ kind: K;
103
+ }> | undefined;
104
+ endpoint(id: string): RegisteredEndpoint | undefined;
105
+ }
106
+ /** Thrown by `validate`, `spec`, `server` and `listen` with every problem the registration has. */
107
+ export declare class RegistrationError extends Error {
108
+ readonly problems: readonly string[];
109
+ constructor(problems: readonly string[]);
110
+ }
111
+ export interface ServiceOptions {
112
+ /** The client handlers receive; the default dials `ANKKA_SIDECAR_ADDRESS`. */
113
+ readonly client?: ComponentClient;
114
+ /** Where log lines go; the default is `console.error`. */
115
+ readonly log?: (message: string) => void;
116
+ }
117
+ export declare class ServiceBuilder {
118
+ #private;
119
+ constructor(options?: ServiceOptions);
120
+ /** The component client every handler receives; the integration testkit repoints it at the sidecar it started. */
121
+ get client(): ComponentClient;
122
+ /** Register a component or endpoint class. A class missing a static the kind requires is refused here, by the type checker. */
123
+ register<S, E, C extends EventSourcedEntity<S, E>>(cls: EventSourcedEntityClass<S, E, C>): this;
124
+ register<S, C extends KeyValueEntity<S>>(cls: KeyValueEntityClass<S, C>): this;
125
+ register<S, C extends Workflow<S>>(cls: WorkflowClass<S, C>): this;
126
+ register<E, Row, C extends View<E, Row>>(cls: ViewClass<E, Row, C>): this;
127
+ register<M, Out, C extends Consumer<M, Out>>(cls: ConsumerClass<M, Out, C>): this;
128
+ register<C extends TimedAction>(cls: TimedActionClass<C>): this;
129
+ register<C extends Agent>(cls: AgentClass<C>): this;
130
+ register<C extends Endpoint>(cls: EndpointClass<C>): this;
131
+ /** Validates every registration and returns the registry, or throws a `RegistrationError` naming every problem. */
132
+ validate(): Registry;
133
+ /** The discovery `Spec` for what is registered, without listening. */
134
+ spec(): Spec;
135
+ /** An unstarted server over the validated registry, for the integration testkit. */
136
+ server(options?: ServerOptions): Server;
137
+ /** Validates, binds `ANKKA_PROCESS_PORT` (default 9010) on loopback, answers discovery, and runs until stopped. */
138
+ listen(options?: ServerOptions): Promise<void>;
139
+ }
140
+ type Fail = (message: string) => void;
141
+ /** Collects a handler table by wire name, reporting a duplicate wire name or a kind the component cannot host. */
142
+ export declare function collectHandlers(table: unknown, what: string, allowed: readonly HandlerRef["kind"][], fail: Fail): ReadonlyMap<string, HandlerRef<any, any, any, any>>;
143
+ /** The entry point: `Ankka.service().register(...).listen()`. */
144
+ export declare const Ankka: Readonly<{
145
+ service(options?: ServiceOptions): ServiceBuilder;
146
+ }>;
147
+ export {};