ankka 0.10.0 → 0.12.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 (94) hide show
  1. package/README.md +1 -0
  2. package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +35 -0
  3. package/dist/_proto/ankka/protocol/v1/agent_pb.js +8 -1
  4. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +858 -2
  5. package/dist/_proto/ankka/protocol/v1/client_pb.js +184 -9
  6. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +10 -1
  7. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +1 -1
  8. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +373 -0
  9. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +81 -16
  10. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +210 -0
  11. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +35 -5
  12. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +86 -0
  13. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +11 -1
  14. package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +61 -0
  15. package/dist/_proto/ankka/protocol/v1/payload_pb.js +18 -1
  16. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +70 -1
  17. package/dist/_proto/ankka/protocol/v1/view_pb.js +11 -1
  18. package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +12 -0
  19. package/dist/_proto/ankka/protocol/v1/wasm_pb.js +1 -1
  20. package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +7 -0
  21. package/dist/_proto/ankka/protocol/v1/workflow_pb.js +1 -1
  22. package/dist/agent.d.ts +15 -1
  23. package/dist/agent.js +20 -0
  24. package/dist/approvals.d.ts +47 -0
  25. package/dist/approvals.js +57 -0
  26. package/dist/autonomous.d.ts +23 -1
  27. package/dist/autonomous.js +30 -1
  28. package/dist/client.d.ts +188 -7
  29. package/dist/client.js +490 -21
  30. package/dist/consumer.d.ts +42 -3
  31. package/dist/consumer.js +35 -1
  32. package/dist/context.d.ts +29 -1
  33. package/dist/context.js +5 -2
  34. package/dist/contract.d.ts +40 -0
  35. package/dist/contract.js +60 -0
  36. package/dist/effects/common.d.ts +7 -0
  37. package/dist/effects/common.js +9 -1
  38. package/dist/effects/eventSourced.d.ts +26 -1
  39. package/dist/effects/eventSourced.js +58 -5
  40. package/dist/effects/keyed.d.ts +33 -0
  41. package/dist/effects/keyed.js +41 -0
  42. package/dist/endpoint.d.ts +10 -1
  43. package/dist/endpoint.js +20 -0
  44. package/dist/eventSourcedEntity.d.ts +8 -3
  45. package/dist/graph.d.ts +24 -2
  46. package/dist/graph.js +27 -1
  47. package/dist/handlers.d.ts +10 -2
  48. package/dist/handlers.js +6 -2
  49. package/dist/index.d.ts +15 -6
  50. package/dist/index.js +12 -3
  51. package/dist/keyedView.d.ts +59 -0
  52. package/dist/keyedView.js +73 -0
  53. package/dist/kinds.js +2 -0
  54. package/dist/mcp.d.ts +57 -0
  55. package/dist/mcp.js +64 -0
  56. package/dist/routes.d.ts +44 -4
  57. package/dist/routes.js +43 -4
  58. package/dist/server/agent.js +12 -4
  59. package/dist/server/discovery.d.ts +17 -1
  60. package/dist/server/discovery.js +182 -1
  61. package/dist/server/eventSourced.d.ts +4 -1
  62. package/dist/server/eventSourced.js +24 -6
  63. package/dist/server/http.d.ts +2 -1
  64. package/dist/server/http.js +80 -4
  65. package/dist/server/stateless.js +42 -3
  66. package/dist/server/workflow.js +5 -1
  67. package/dist/service.d.ts +46 -2
  68. package/dist/service.js +235 -15
  69. package/dist/services.d.ts +137 -0
  70. package/dist/services.js +259 -0
  71. package/dist/socket.d.ts +15 -0
  72. package/dist/socket.js +25 -0
  73. package/dist/spec.d.ts +1 -1
  74. package/dist/spec.js +94 -15
  75. package/dist/standing.d.ts +18 -0
  76. package/dist/standing.js +26 -0
  77. package/dist/startFrom.d.ts +36 -0
  78. package/dist/startFrom.js +51 -0
  79. package/dist/testkit/index.d.ts +2 -2
  80. package/dist/testkit/index.js +1 -1
  81. package/dist/testkit/integration.js +5 -0
  82. package/dist/testkit/kinds.d.ts +50 -5
  83. package/dist/testkit/kinds.js +208 -35
  84. package/dist/testkit/unit.d.ts +41 -3
  85. package/dist/testkit/unit.js +87 -10
  86. package/dist/timedAction.d.ts +17 -2
  87. package/dist/timedAction.js +33 -1
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/dist/view.d.ts +48 -1
  91. package/dist/view.js +18 -1
  92. package/dist/workflow.d.ts +18 -1
  93. package/dist/workflow.js +39 -0
  94. package/package.json +3 -2
package/dist/client.js CHANGED
@@ -3,16 +3,19 @@
3
3
  // One channel per process, opened on first use so the integration testkit can learn the sidecar's
4
4
  // mapped port after construction. A client handed to a handler is scoped to that request's metadata,
5
5
  // so the sidecar records the call as a child span.
6
- import { createClient } from "@connectrpc/connect";
6
+ import { Code, ConnectError, createClient } from "@connectrpc/connect";
7
7
  import { createGrpcTransport } from "@connectrpc/connect-node";
8
- import { Client } from "./_proto/ankka/protocol/v1/client_pb.js";
8
+ import { Client, Overflow as ProtoOverflow } from "./_proto/ankka/protocol/v1/client_pb.js";
9
9
  import { codecFor, jsonCodec, isCodec, textCodecs, binaryCodecs, codecForManifest } from "./codec.js";
10
10
  import { CommandError } from "./effects/common.js";
11
11
  import { decodeJsonValue, reviver } from "./json.js";
12
12
  import { errorCodeFromProto, kindToProto } from "./kinds.js";
13
13
  import { metadataToProto } from "./context.js";
14
14
  import { encodePayload, decodePayload, EMPTY_PAYLOAD } from "./server/payloads.js";
15
+ import { Duration } from "./time.js";
16
+ import { PROTOCOL_VERSION } from "./spec.js";
15
17
  import { AutonomousAgentCalls, TaskCalls, Tasks } from "./autonomous.js";
18
+ import { APPROVALS_SINCE, ApprovalAwaited, awaitingOf } from "./approvals.js";
16
19
  function stubOf(connection) {
17
20
  if (!connection.stub) {
18
21
  connection.transport = createGrpcTransport({ baseUrl: `http://${connection.address}` });
@@ -21,7 +24,28 @@ function stubOf(connection) {
21
24
  return connection.stub;
22
25
  }
23
26
  function errorOf(error) {
24
- return { message: error.message, code: errorCodeFromProto(error.code) };
27
+ return { message: error.message, code: errorCodeFromProto(error.code), details: { ...(error.details ?? {}) } };
28
+ }
29
+ /** The first protocol whose runtime can wait for a workflow's end; an older one answers `UNIMPLEMENTED`. */
30
+ export const AWAIT_SINCE = "1.18";
31
+ /** A part as one line of JSON, which is what an `sse` route yields for it. */
32
+ export function awaitPartJson(part) {
33
+ switch (part.kind) {
34
+ case "heartbeat":
35
+ return JSON.stringify({ heartbeat: true });
36
+ case "ended":
37
+ return JSON.stringify({ ended: part.state });
38
+ case "failed":
39
+ return JSON.stringify({ failed: { code: part.error.code, message: part.error.message, ...part.error.details } });
40
+ case "timed-out":
41
+ return JSON.stringify({ timedOut: true });
42
+ }
43
+ }
44
+ function timeoutOf(timeoutMillis) {
45
+ if (!(timeoutMillis > 0)) {
46
+ throw new CommandError({ message: `a wait for a workflow's end needs a timeout of more than zero, not ${timeoutMillis}`, code: "BAD_REQUEST" });
47
+ }
48
+ return Math.max(1, Math.floor(timeoutMillis));
25
49
  }
26
50
  function inputPayload(shape, input) {
27
51
  if (shape === undefined) {
@@ -38,6 +62,43 @@ function decodeReply(shape, payload) {
38
62
  const codec = payload ? codecForManifest(payload.manifest) : undefined;
39
63
  return (codec ?? binaryCodecs.done).decode(payload?.data ?? new Uint8Array());
40
64
  }
65
+ function outcomeOf(reply, answer) {
66
+ switch (answer.result.case) {
67
+ case "reply":
68
+ return { kind: "answered", value: decodeReply(reply, answer.result.value.payload) };
69
+ case "approval":
70
+ return awaitingOf(answer.result.value);
71
+ case "error":
72
+ throw new CommandError(errorOf(answer.result.value));
73
+ default:
74
+ throw new CommandError({ message: "the sidecar answered nothing", code: "INTERNAL" });
75
+ }
76
+ }
77
+ /** Sends a decision; a runtime before approvals is reported as too old. */
78
+ async function decideOn(connection, metadata, kind, componentId, entityId, name, approvalId, decision) {
79
+ try {
80
+ return await stubOf(connection).decide({
81
+ kind: kindToProto(kind),
82
+ componentId,
83
+ entityId,
84
+ name,
85
+ approvalId,
86
+ approved: decision.approved,
87
+ by: decision.by,
88
+ ...(decision.note ? { note: decision.note } : {}),
89
+ metadata: metadataToProto(metadata),
90
+ });
91
+ }
92
+ catch (failure) {
93
+ if (failure instanceof ConnectError && failure.code === Code.Unimplemented) {
94
+ throw new CommandError({
95
+ message: `the runtime beside this process cannot record a decision on an approval request, which needs protocol ${APPROVALS_SINCE}: ${failure.rawMessage}`,
96
+ code: "INTERNAL",
97
+ });
98
+ }
99
+ throw failure;
100
+ }
101
+ }
41
102
  /** One call, ready to invoke or stream. */
42
103
  export class Invocation {
43
104
  #connection;
@@ -48,7 +109,9 @@ export class Invocation {
48
109
  #name;
49
110
  #input;
50
111
  #reply;
51
- constructor(connection, metadata, kind, componentId, entityId, name, input, reply) {
112
+ #state;
113
+ constructor(connection, metadata, kind, componentId, entityId, name, input, reply, state) {
114
+ this.#state = state;
52
115
  this.#connection = connection;
53
116
  this.#metadata = metadata;
54
117
  this.#kind = kind;
@@ -68,20 +131,48 @@ export class Invocation {
68
131
  metadata: metadataToProto(this.#metadata),
69
132
  };
70
133
  }
71
- /** Invokes and decodes the reply; a refusal rejects with `CommandError`. */
134
+ /**
135
+ * Invokes and decodes the reply; a refusal rejects with `CommandError`. An agent's turn that waits for a
136
+ * person rejects with `ApprovalAwaited`; `ask` answers it instead.
137
+ */
72
138
  async invoke(input) {
73
- const answer = await stubOf(this.#connection).invoke(this.#request(input));
74
- switch (answer.result.case) {
75
- case "reply":
76
- return decodeReply(this.#reply, answer.result.value.payload);
77
- case "error":
78
- throw new CommandError(errorOf(answer.result.value));
79
- default:
80
- throw new CommandError({ message: "the sidecar answered nothing", code: "INTERNAL" });
81
- }
139
+ const outcome = await this.ask(input);
140
+ if (outcome.kind === "awaiting-approval")
141
+ throw new ApprovalAwaited(outcome.requests);
142
+ return outcome.value;
143
+ }
144
+ /**
145
+ * Sends this command to the workflow and then waits for its end, as one call: answered with the state it
146
+ * ended with, within `timeoutMillis` of the command being sent. A refusal of the command rejects at once and
147
+ * no wait begins; the command's own reply is not kept. `state` decodes it, unless the workflow class did.
148
+ */
149
+ thenAwaitEnd(timeoutMillis, state) {
150
+ const shape = (state ?? this.#state);
151
+ const calls = new Calls(this.#connection, this.#metadata, this.#kind, this.#componentId, this.#entityId, shape);
152
+ return new AwaitingInvocation(timeoutMillis, (input) => this.invoke(input), (left) => calls.awaitEnd(left));
153
+ }
154
+ /** Calls an agent's handler for its outcome: the model's answer, or the approval requests the turn waits on. */
155
+ async ask(input) {
156
+ return outcomeOf(this.#reply, await stubOf(this.#connection).invoke(this.#request(input)));
82
157
  }
83
- /** Invokes a streaming handler; yields tokens in order; a failure rejects with `CommandError`. */
158
+ /**
159
+ * Decides one of the session's approval requests; this invocation names the handler whose turn waits.
160
+ * `by` names who decided and is required. When it was the turn's last awaited decision the turn goes on,
161
+ * and this answers as `ask` would have. A request already decided is a conflict.
162
+ */
163
+ async decide(approvalId, decision) {
164
+ return outcomeOf(this.#reply, await decideOn(this.#connection, this.#metadata, this.#kind, this.#componentId, this.#entityId, this.#name, approvalId, decision));
165
+ }
166
+ /** Invokes a streaming handler; yields tokens in order; a failure rejects with `CommandError`, a turn that waits with `ApprovalAwaited`. */
84
167
  async *stream(input) {
168
+ for await (const part of this.streamParts(input)) {
169
+ if (typeof part !== "string")
170
+ throw new ApprovalAwaited(part.requests);
171
+ yield part;
172
+ }
173
+ }
174
+ /** As `stream`, ending with one awaiting part when the turn waits for a person. */
175
+ async *streamParts(input) {
85
176
  for await (const token of stubOf(this.#connection).invokeStream(this.#request(input))) {
86
177
  switch (token.token.case) {
87
178
  case "text":
@@ -91,10 +182,39 @@ export class Invocation {
91
182
  return;
92
183
  case "failed":
93
184
  throw new CommandError(errorOf(token.token.value));
185
+ case "approval":
186
+ yield awaitingOf(token.token.value);
187
+ return;
94
188
  }
95
189
  }
96
190
  }
97
191
  }
192
+ /** A command to a workflow followed by a wait for its end, with one deadline from the send. */
193
+ export class AwaitingInvocation {
194
+ #timeoutMillis;
195
+ #send;
196
+ #await;
197
+ constructor(timeoutMillis, send, wait) {
198
+ this.#timeoutMillis = timeoutMillis;
199
+ this.#send = send;
200
+ this.#await = wait;
201
+ }
202
+ /** Sends the command and waits for the end; rejects as `Calls.awaitEnd` does. */
203
+ async invoke(input) {
204
+ const deadline = Date.now() + timeoutOf(this.#timeoutMillis);
205
+ await this.#send(input);
206
+ return await this.#await(Math.max(1, deadline - Date.now()));
207
+ }
208
+ }
209
+ const awaitTooOld = (failure) => {
210
+ if (failure instanceof ConnectError && failure.code === Code.Unimplemented) {
211
+ throw new CommandError({
212
+ message: `the runtime beside this process is too old for waiting for a workflow's end, which needs protocol ${AWAIT_SINCE}; this process speaks ${PROTOCOL_VERSION}: ${failure.rawMessage}`,
213
+ code: "UNAVAILABLE",
214
+ });
215
+ }
216
+ throw failure;
217
+ };
98
218
  /** Calls on one component instance, by wire name. */
99
219
  export class Calls {
100
220
  #connection;
@@ -102,16 +222,67 @@ export class Calls {
102
222
  #kind;
103
223
  #componentId;
104
224
  #entityId;
105
- constructor(connection, metadata, kind, componentId, entityId) {
225
+ #state;
226
+ constructor(connection, metadata, kind, componentId, entityId, state) {
106
227
  this.#connection = connection;
107
228
  this.#metadata = metadata;
108
229
  this.#kind = kind;
109
230
  this.#componentId = componentId;
110
231
  this.#entityId = entityId;
232
+ this.#state = state;
111
233
  }
112
234
  /** `call("add-item", LineItem, Done)`: the wire name, the input shape (omit for none) and the reply shape (omit for `done`). */
113
235
  call(name, input, reply) {
114
- return new Invocation(this.#connection, this.#metadata, this.#kind, this.#componentId, this.#entityId, name, input, reply);
236
+ return new Invocation(this.#connection, this.#metadata, this.#kind, this.#componentId, this.#entityId, name, input, reply, this.#state);
237
+ }
238
+ #awaitRequest(timeoutMillis) {
239
+ return {
240
+ componentId: this.#componentId,
241
+ entityId: this.#entityId,
242
+ timeoutMillis: BigInt(timeoutOf(timeoutMillis)),
243
+ metadata: metadataToProto(this.#metadata),
244
+ };
245
+ }
246
+ /**
247
+ * Waits for this workflow's end, for at most `timeoutMillis`, and resolves with the state it ended with — at
248
+ * once when it already has. A workflow that failed or was deleted rejects with `CommandError` whose code is
249
+ * `WORKFLOW_FAILED` and whose `details` hold `step`, `reason` and `deleted`; a wait not answered in time
250
+ * rejects with `TIMEOUT` and the workflow runs on. A paused workflow has not ended. There is no default.
251
+ */
252
+ async awaitEnd(timeoutMillis, state) {
253
+ const shape = (state ?? this.#state);
254
+ const answer = await stubOf(this.#connection).awaitEnd(this.#awaitRequest(timeoutMillis)).catch(awaitTooOld);
255
+ const outcome = outcomeOf(shape, answer);
256
+ if (outcome.kind !== "answered")
257
+ throw new CommandError({ message: "a workflow answered a wait as an agent", code: "INTERNAL" });
258
+ return outcome.value;
259
+ }
260
+ /**
261
+ * The same wait as a stream, for an `sse` route that must outlast the idle timeout: a heartbeat while it goes
262
+ * on, then exactly one `ended`, `failed` or `timed-out` part. `awaitPartJson` renders a part as a line.
263
+ */
264
+ async *awaitEndParts(timeoutMillis, state) {
265
+ const shape = (state ?? this.#state);
266
+ try {
267
+ for await (const token of stubOf(this.#connection).awaitEndStream(this.#awaitRequest(timeoutMillis))) {
268
+ switch (token.token.case) {
269
+ case "heartbeat":
270
+ yield { kind: "heartbeat" };
271
+ break;
272
+ case "ended":
273
+ yield { kind: "ended", state: decodeReply(shape, token.token.value) };
274
+ return;
275
+ case "failed": {
276
+ const error = new CommandError(errorOf(token.token.value));
277
+ yield error.code === "TIMEOUT" ? { kind: "timed-out", message: error.message } : { kind: "failed", error };
278
+ return;
279
+ }
280
+ }
281
+ }
282
+ }
283
+ catch (failure) {
284
+ awaitTooOld(failure);
285
+ }
115
286
  }
116
287
  }
117
288
  /** Calls on one component instance through its class's handler table, fully typed. */
@@ -124,12 +295,47 @@ export class TypedCalls {
124
295
  call(handler) {
125
296
  return this.#calls.call(handler.name, handler.input, handler.reply);
126
297
  }
298
+ /** For a workflow: waits for its end and resolves with its state, decoded as the class declares it. */
299
+ awaitEnd(timeoutMillis) {
300
+ return this.#calls.awaitEnd(timeoutMillis);
301
+ }
302
+ /** For a workflow: the same wait as a stream of parts. */
303
+ awaitEndParts(timeoutMillis) {
304
+ return this.#calls.awaitEndParts(timeoutMillis);
305
+ }
306
+ }
307
+ /** The first protocol in which a view's query can be streamed and watched. */
308
+ export const VIEW_STREAMS_SINCE = "1.17";
309
+ /** A watch ended for `reason`: `rebuilt`, `instance-stopping`, `listener-lost` or `unread`. Watch again for the rows now. */
310
+ export class WatchEnded extends Error {
311
+ reason;
312
+ constructor(reason) {
313
+ super(`the watch ended: ${reason}`);
314
+ this.name = "WatchEnded";
315
+ this.reason = reason;
316
+ }
317
+ }
318
+ function overflowToProto(overflow) {
319
+ switch (overflow) {
320
+ case "dropHead":
321
+ return ProtoOverflow.DROP_HEAD;
322
+ case "dropTail":
323
+ return ProtoOverflow.DROP_TAIL;
324
+ case "dropNew":
325
+ return ProtoOverflow.DROP_NEW;
326
+ case "dropAll":
327
+ return ProtoOverflow.DROP_ALL;
328
+ case "fail":
329
+ return ProtoOverflow.FAIL;
330
+ }
127
331
  }
128
332
  /** View queries: rows come back as a JSON array decoded with the row shape. */
129
333
  export class Views {
130
334
  #connection;
131
- constructor(connection) {
335
+ #metadata;
336
+ constructor(connection, metadata = {}) {
132
337
  this.#connection = connection;
338
+ this.#metadata = metadata;
133
339
  }
134
340
  async get(viewId, key, row) {
135
341
  const rows = await this.query(viewId, "get", key, row);
@@ -139,7 +345,101 @@ export class Views {
139
345
  return this.query(viewId, "all", null, row);
140
346
  }
141
347
  async query(viewId, name, key, row) {
142
- const answer = await stubOf(this.#connection).query({ viewId, name, payload: encodePayload(textCodecs.string, key ?? "") });
348
+ const answer = await stubOf(this.#connection).query({ viewId, name, payload: encodePayload(textCodecs.string, key ?? ""), metadata: metadataToProto(this.#metadata) });
349
+ return Views.#rows(answer, row);
350
+ }
351
+ /**
352
+ * The rows of one of a view's declared queries, asked by name with the values it takes. At most
353
+ * `limit` rows, 1000 when it is not given, in the statement's own order.
354
+ */
355
+ async ask(viewId, name, values, row, limit) {
356
+ const answer = await stubOf(this.#connection).query({
357
+ viewId,
358
+ name,
359
+ metadata: metadataToProto(this.#metadata),
360
+ values: { ...values },
361
+ ...(limit !== undefined ? { limit } : {}),
362
+ });
363
+ return Views.#rows(answer, row);
364
+ }
365
+ /**
366
+ * The rows of `all` or one of a view's declared queries as the database yields them: nothing
367
+ * collected, and no limit unless one is given. A statement the database ends, or a reader that
368
+ * takes nothing for its timeout, throws.
369
+ */
370
+ async *stream(viewId, name, values, row, options = {}) {
371
+ const frames = stubOf(this.#connection).queryStream({
372
+ viewId,
373
+ name,
374
+ metadata: metadataToProto(this.#metadata),
375
+ values: { ...values },
376
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
377
+ });
378
+ for await (const event of Views.#frames(frames, row))
379
+ if (event.kind === "row")
380
+ yield event.row;
381
+ }
382
+ /**
383
+ * A watch of one of a view's declared queries, declared `{ watched: true }`: every row it matches
384
+ * now, then `caughtUp`, then each row as it is written and matches and a `removed` for each given row
385
+ * that stops matching. Throws `WatchEnded` when it ends with a reason. `unread` bounds the rows held
386
+ * for a reader that has not read them, and `overflow` says what happens past that bound.
387
+ */
388
+ async *watch(viewId, name, values, row, options = {}) {
389
+ const frames = stubOf(this.#connection).watch({
390
+ viewId,
391
+ metadata: metadataToProto(this.#metadata),
392
+ target: { case: "named", value: { name, values: { ...values } } },
393
+ ...(options.unread !== undefined ? { unreadBound: options.unread } : {}),
394
+ overflow: overflowToProto(options.overflow ?? "dropHead"),
395
+ });
396
+ yield* Views.#frames(frames, row);
397
+ }
398
+ /** A watch of one row by its key: the row now, then `caughtUp`, then each version and a `removed` when it is deleted. */
399
+ async *watchRow(viewId, key, row, options = {}) {
400
+ const frames = stubOf(this.#connection).watch({
401
+ viewId,
402
+ metadata: metadataToProto(this.#metadata),
403
+ target: { case: "key", value: key },
404
+ ...(options.unread !== undefined ? { unreadBound: options.unread } : {}),
405
+ overflow: overflowToProto(options.overflow ?? "dropHead"),
406
+ });
407
+ yield* Views.#frames(frames, row);
408
+ }
409
+ static async *#frames(frames, row) {
410
+ const codec = isCodec(row) ? row : codecFor(row);
411
+ try {
412
+ for await (const frame of frames) {
413
+ switch (frame.frame.case) {
414
+ case "row":
415
+ yield { kind: "row", key: frame.frame.value.key, row: codec.decode(frame.frame.value.payload?.data ?? new Uint8Array()) };
416
+ break;
417
+ case "removed":
418
+ yield { kind: "removed", key: frame.frame.value.key };
419
+ break;
420
+ case "caughtUp":
421
+ yield { kind: "caughtUp" };
422
+ break;
423
+ case "ended":
424
+ throw new WatchEnded(frame.frame.value.reason);
425
+ case "failed":
426
+ throw new CommandError(errorOf(frame.frame.value));
427
+ default:
428
+ return;
429
+ }
430
+ }
431
+ }
432
+ catch (failure) {
433
+ if (failure instanceof ConnectError && failure.code === Code.Unimplemented) {
434
+ throw new CommandError({
435
+ message: `the runtime beside this process does not offer view streams, which need protocol ${VIEW_STREAMS_SINCE} (this SDK speaks ${PROTOCOL_VERSION}): ${failure.rawMessage}`,
436
+ code: "INTERNAL",
437
+ });
438
+ }
439
+ throw failure;
440
+ }
441
+ }
442
+ static #rows(answer, row) {
143
443
  switch (answer.result.case) {
144
444
  case "rows": {
145
445
  const text = new TextDecoder().decode(answer.result.value.data);
@@ -159,6 +459,16 @@ export class Views {
159
459
  }
160
460
  }
161
461
  }
462
+ const RECURRING_SINCE = "1.12";
463
+ const MAX_PERIOD_DAYS = 36500;
464
+ const MIN_PERIOD = Duration.ofMillis(1);
465
+ const MAX_PERIOD = Duration.ofSeconds(MAX_PERIOD_DAYS * 86400);
466
+ /** What is wrong with a recurring timer's period, if anything: the runtime's own rule. */
467
+ function periodProblem(timerId, period) {
468
+ if (Duration.compare(period, MIN_PERIOD) >= 0 && Duration.compare(period, MAX_PERIOD) <= 0)
469
+ return undefined;
470
+ return `timer '${timerId}' has a period of ${period.toString()}; a period is from 1 millisecond to ${MAX_PERIOD_DAYS} days`;
471
+ }
162
472
  export class Timers {
163
473
  #connection;
164
474
  constructor(connection) {
@@ -179,10 +489,161 @@ export class Timers {
179
489
  payload: inputPayload(t.input, input),
180
490
  });
181
491
  }
492
+ /**
493
+ * Schedules `target`, a timed action's handler, first after `delay` (zero or less is due at once) and
494
+ * then every `period` until it is cancelled or replaced. Setting it again with the same handler and
495
+ * period keeps its next due time, so a service may set its recurring timers every time it starts.
496
+ * A period outside 1 millisecond to 36,500 days is refused with `BAD_REQUEST` before anything is sent.
497
+ */
498
+ async scheduleRecurring(timerId, delay, period, target, input) {
499
+ const problem = periodProblem(timerId, period);
500
+ if (problem !== undefined)
501
+ throw new CommandError({ message: problem, code: "BAD_REQUEST" });
502
+ const t = "component" in target ? { componentId: target.component.componentId, name: target.handler.name, input: target.handler.input } : target;
503
+ const reply = await stubOf(this.#connection)
504
+ .scheduleRecurring({
505
+ timerId,
506
+ delayMillis: BigInt(delay.toMillis()),
507
+ periodMillis: BigInt(period.toMillis()),
508
+ componentId: t.componentId,
509
+ name: t.name,
510
+ payload: inputPayload(t.input, input),
511
+ })
512
+ .catch(tooOld("recurring timers", RECURRING_SINCE));
513
+ if (reply.error)
514
+ throw new CommandError(errorOf(reply.error));
515
+ }
516
+ /** Cancels the timer under `timerId`, whether it fires once or recurs. */
182
517
  async cancel(timerId) {
183
518
  await stubOf(this.#connection).cancel({ timerId });
184
519
  }
185
520
  }
521
+ // ── The secret store (protocol 1.6) ───────────────────────────────────────────
522
+ const SECRETS_SINCE = "1.6";
523
+ export const MAX_SECRET_NAME_LENGTH = 253;
524
+ export const MAX_SECRET_VALUE_BYTES = 65536;
525
+ const SECRET_NAME_RULE = `a secret's name is 1 to ${MAX_SECRET_NAME_LENGTH} characters, each a letter, a digit, '.', '_', '-' or '/'`;
526
+ const VALID_SECRET_NAME = new RegExp(`^[A-Za-z0-9._/-]{1,${MAX_SECRET_NAME_LENGTH}}$`);
527
+ /** What is wrong with a secret's name, if anything: the runtime's own rule. */
528
+ export function secretNameProblem(name) {
529
+ if (VALID_SECRET_NAME.test(name))
530
+ return undefined;
531
+ const shown = name.length > 40 ? `${name.slice(0, 40)}…` : name;
532
+ return `${SECRET_NAME_RULE}; "${shown}" is not`;
533
+ }
534
+ /** What is wrong with a secret's value, if anything. Never quotes the value. */
535
+ export function secretValueProblem(value) {
536
+ if (value === "")
537
+ return "a secret's value must not be empty";
538
+ const size = new TextEncoder().encode(value).length;
539
+ return size > MAX_SECRET_VALUE_BYTES ? `a secret's value is at most ${MAX_SECRET_VALUE_BYTES} bytes as UTF-8; this one is ${size}` : undefined;
540
+ }
541
+ /** Turns `UNIMPLEMENTED` from a runtime older than protocol `since` into an error naming both versions. */
542
+ function tooOld(what, since) {
543
+ return (failure) => {
544
+ if (failure instanceof ConnectError && failure.code === Code.Unimplemented) {
545
+ throw new CommandError({
546
+ message: `the runtime beside this process is too old for ${what}, which needs protocol ${since}; this process speaks ${PROTOCOL_VERSION}: ${failure.rawMessage}`,
547
+ code: "INTERNAL",
548
+ });
549
+ }
550
+ throw failure;
551
+ };
552
+ }
553
+ const secretsTooOld = tooOld("the secret store", SECRETS_SINCE);
554
+ /**
555
+ * The service's secret store: named text values the runtime keeps in the service's own database,
556
+ * encrypted with a key this process never sees. Offered to endpoints, workflow steps, consumers,
557
+ * timed actions and agents; an entity and a view have no `secrets` at all.
558
+ *
559
+ * Every refusal is a `CommandError` with the runtime's code: `BAD_REQUEST` for a name or a value that
560
+ * breaks its rule, `INTERNAL` when the service has no secret key or a value was kept with another
561
+ * one, `UNAVAILABLE` when the database cannot be reached.
562
+ */
563
+ export class Secrets {
564
+ #client;
565
+ /** The store reached through `client`'s sidecar; with none, every call throws. */
566
+ constructor(client) {
567
+ this.#client = client;
568
+ }
569
+ #stub() {
570
+ const connection = this.#client ? connectionOf.get(this.#client) : undefined;
571
+ if (!connection)
572
+ throw new Error("this secret store is not connected to a runtime; a unit test passes an InMemorySecrets");
573
+ return stubOf(connection);
574
+ }
575
+ /** Keeps `value` under `name`, replacing what was there. */
576
+ async put(name, value) {
577
+ const reply = await this.#stub().putSecret({ name, value }).catch(secretsTooOld);
578
+ if (reply.error)
579
+ throw new CommandError(errorOf(reply.error));
580
+ }
581
+ /** The value kept under `name`, or `undefined` when there is none. Never an empty string. */
582
+ async get(name) {
583
+ const reply = await this.#stub().getSecret({ name }).catch(secretsTooOld);
584
+ switch (reply.result.case) {
585
+ case "value":
586
+ return reply.result.value;
587
+ case "absent":
588
+ return undefined;
589
+ case "error":
590
+ throw new CommandError(errorOf(reply.result.value));
591
+ default:
592
+ throw new CommandError({ message: "the runtime answered a secret's read with nothing", code: "INTERNAL" });
593
+ }
594
+ }
595
+ /** Removes what is kept under `name`. Removing nothing is not an error. */
596
+ async delete(name) {
597
+ const reply = await this.#stub().deleteSecret({ name }).catch(secretsTooOld);
598
+ if (reply.error)
599
+ throw new CommandError(errorOf(reply.error));
600
+ }
601
+ }
602
+ /** A secret store for unit tests: a map, applying the runtime's rules with its words. */
603
+ export class InMemorySecrets extends Secrets {
604
+ values = new Map();
605
+ async put(name, value) {
606
+ const problem = secretNameProblem(name) ?? secretValueProblem(value);
607
+ if (problem)
608
+ throw new CommandError({ message: problem, code: "BAD_REQUEST" });
609
+ this.values.set(name, value);
610
+ }
611
+ async get(name) {
612
+ const problem = secretNameProblem(name);
613
+ if (problem)
614
+ throw new CommandError({ message: problem, code: "BAD_REQUEST" });
615
+ return this.values.get(name);
616
+ }
617
+ async delete(name) {
618
+ const problem = secretNameProblem(name);
619
+ if (problem)
620
+ throw new CommandError({ message: problem, code: "BAD_REQUEST" });
621
+ this.values.delete(name);
622
+ }
623
+ }
624
+ /** A store for unit tests whose every call throws: the unit kit's default, beside `noClient()`. */
625
+ export function noSecrets() {
626
+ return new Secrets(undefined);
627
+ }
628
+ const connectionOf = new WeakMap();
629
+ const metadataOf = new WeakMap();
630
+ /**
631
+ * @internal What a call to another service is sent through: `client`'s sidecar, and the metadata of
632
+ * the handler `client` was scoped to. Not a member of the client, which an entity holds too.
633
+ */
634
+ export function channelOf(client) {
635
+ const connection = connectionOf.get(client);
636
+ if (!connection)
637
+ return undefined;
638
+ return { stub: () => stubOf(connection), metadata: metadataOf.get(client) ?? {} };
639
+ }
640
+ /**
641
+ * @internal The secret store reached through `client`'s sidecar. Not a member of the client, which
642
+ * an entity holds too: only the component kinds that may have a store call this.
643
+ */
644
+ export function secretsFor(client) {
645
+ return new Secrets(client);
646
+ }
186
647
  export class ComponentClient {
187
648
  #connection;
188
649
  #metadata;
@@ -191,8 +652,10 @@ export class ComponentClient {
191
652
  constructor(address = process.env.ANKKA_SIDECAR_ADDRESS ?? "127.0.0.1:9011", connection, metadata = {}) {
192
653
  this.#connection = connection ?? { address, transport: undefined, stub: undefined };
193
654
  this.#metadata = metadata;
194
- this.views = new Views(this.#connection);
655
+ this.views = new Views(this.#connection, metadata);
195
656
  this.timers = new Timers(this.#connection);
657
+ connectionOf.set(this, this.#connection);
658
+ metadataOf.set(this, metadata);
196
659
  }
197
660
  /** Points every client sharing this connection at a new sidecar address (the integration testkit's mapped port). */
198
661
  reconnect(address) {
@@ -253,6 +716,10 @@ export class ComponentClient {
253
716
  throw new CommandError({ message: "the sidecar answered nothing", code: "INTERNAL" });
254
717
  }
255
718
  }
719
+ /** @internal A decision on an approval request; a refusal rejects with `CommandError`. */
720
+ async _decide(kind, componentId, entityId, name, approvalId, decision) {
721
+ outcomeOf(undefined, await decideOn(this.#connection, this.#metadata, kind, componentId, entityId, name, approvalId, decision));
722
+ }
256
723
  /** @internal A streaming call whose tokens are text. */
257
724
  async *_rawStream(kind, componentId, entityId, name) {
258
725
  const request = { kind: kindToProto(kind), componentId, entityId, name, payload: EMPTY_PAYLOAD, metadata: metadataToProto(this.#metadata) };
@@ -270,7 +737,9 @@ export class ComponentClient {
270
737
  }
271
738
  /** Typed calls through a component class's handler table: `client.of(ShoppingCartEntity, cartId).call(ShoppingCartEntity.handlers.addItem).invoke(item)`. */
272
739
  of(component, entityId) {
273
- return new TypedCalls(new Calls(this.#connection, this.#metadata, component.prototype._kind, component.componentId, entityId));
740
+ // A workflow class declares its state's shape: what a wait for its end decodes the state with.
741
+ const state = component.state;
742
+ return new TypedCalls(new Calls(this.#connection, this.#metadata, component.prototype._kind, component.componentId, entityId, state));
274
743
  }
275
744
  }
276
745
  /** A client for unit tests: every call throws, naming what was attempted. */