ankka 0.11.0 → 0.13.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 (92) hide show
  1. package/README.md +1 -0
  2. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +910 -0
  3. package/dist/_proto/ankka/protocol/v1/client_pb.js +239 -23
  4. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +10 -1
  5. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +1 -1
  6. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +177 -0
  7. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +51 -36
  8. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +26 -0
  9. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +15 -10
  10. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +86 -0
  11. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +11 -1
  12. package/dist/_proto/ankka/protocol/v1/mcp_pb.d.ts +120 -0
  13. package/dist/_proto/ankka/protocol/v1/mcp_pb.js +29 -0
  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 +7 -1
  17. package/dist/_proto/ankka/protocol/v1/view_pb.js +1 -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/agent.d.ts +10 -0
  21. package/dist/agent.js +20 -0
  22. package/dist/autonomous.d.ts +5 -0
  23. package/dist/autonomous.js +10 -0
  24. package/dist/client-keys.d.ts +89 -0
  25. package/dist/client-keys.js +182 -0
  26. package/dist/client.d.ts +96 -3
  27. package/dist/client.js +228 -6
  28. package/dist/consumer.d.ts +23 -1
  29. package/dist/consumer.js +25 -1
  30. package/dist/context.d.ts +25 -1
  31. package/dist/context.js +5 -2
  32. package/dist/contract.d.ts +4 -0
  33. package/dist/contract.js +2 -0
  34. package/dist/effects/common.d.ts +7 -0
  35. package/dist/effects/common.js +9 -1
  36. package/dist/effects/eventSourced.d.ts +26 -1
  37. package/dist/effects/eventSourced.js +58 -5
  38. package/dist/endpoint.d.ts +10 -0
  39. package/dist/endpoint.js +20 -0
  40. package/dist/eventSourcedEntity.d.ts +8 -3
  41. package/dist/graph.d.ts +14 -1
  42. package/dist/graph.js +17 -1
  43. package/dist/index.d.ts +10 -4
  44. package/dist/index.js +8 -2
  45. package/dist/keyedView.d.ts +4 -1
  46. package/dist/keyedView.js +7 -1
  47. package/dist/kinds.js +2 -0
  48. package/dist/mcp-endpoint.d.ts +144 -0
  49. package/dist/mcp-endpoint.js +256 -0
  50. package/dist/routes.d.ts +46 -3
  51. package/dist/routes.js +56 -3
  52. package/dist/server/discovery.d.ts +12 -0
  53. package/dist/server/discovery.js +115 -4
  54. package/dist/server/eventSourced.d.ts +4 -1
  55. package/dist/server/eventSourced.js +24 -6
  56. package/dist/server/http.d.ts +8 -0
  57. package/dist/server/http.js +12 -4
  58. package/dist/server/mcp.d.ts +17 -0
  59. package/dist/server/mcp.js +157 -0
  60. package/dist/server/server.d.ts +11 -1
  61. package/dist/server/server.js +19 -0
  62. package/dist/server/stateless.js +5 -4
  63. package/dist/server/storageEndpoint.d.ts +14 -0
  64. package/dist/server/storageEndpoint.js +82 -0
  65. package/dist/service.d.ts +12 -1
  66. package/dist/service.js +73 -5
  67. package/dist/sigv4.d.ts +47 -0
  68. package/dist/sigv4.js +149 -0
  69. package/dist/spec.d.ts +8 -2
  70. package/dist/spec.js +62 -6
  71. package/dist/standing.d.ts +18 -0
  72. package/dist/standing.js +26 -0
  73. package/dist/startFrom.d.ts +4 -0
  74. package/dist/startFrom.js +8 -1
  75. package/dist/storage.d.ts +288 -0
  76. package/dist/storage.js +1081 -0
  77. package/dist/testkit/index.d.ts +1 -1
  78. package/dist/testkit/integration.d.ts +9 -0
  79. package/dist/testkit/integration.js +5 -1
  80. package/dist/testkit/kinds.d.ts +25 -9
  81. package/dist/testkit/kinds.js +53 -28
  82. package/dist/testkit/unit.d.ts +31 -3
  83. package/dist/testkit/unit.js +55 -11
  84. package/dist/timedAction.d.ts +10 -0
  85. package/dist/timedAction.js +20 -0
  86. package/dist/version.d.ts +1 -1
  87. package/dist/version.js +1 -1
  88. package/dist/view.d.ts +18 -3
  89. package/dist/view.js +13 -4
  90. package/dist/workflow.d.ts +8 -0
  91. package/dist/workflow.js +15 -0
  92. package/package.json +1 -1
package/dist/routes.js CHANGED
@@ -11,12 +11,15 @@
11
11
  import { isCodec } from "./codec.js";
12
12
  /**
13
13
  * The callers an endpoint can name: `Acl.allowCallers(Callers.internet, Callers.service("orders"))`. A named
14
- * service is in this service's own project unless `project` names another.
14
+ * service is in this service's own project unless `project` names another. `Callers.granted` is whoever this
15
+ * service's project has granted the route to — a service of another project, or a registered machine
16
+ * (protocol 1.15); a route that does not name it can never be granted.
15
17
  */
16
18
  export const Callers = Object.freeze({
17
19
  internet: Object.freeze({ kind: "internet" }),
18
20
  anyInProject: Object.freeze({ kind: "anyInProject" }),
19
21
  self: Object.freeze({ kind: "self" }),
22
+ granted: Object.freeze({ kind: "granted" }),
20
23
  service(name, options) {
21
24
  return Object.freeze(options?.project !== undefined ? { kind: "service", name, project: options.project } : { kind: "service", name });
22
25
  },
@@ -25,19 +28,31 @@ export const Acl = Object.freeze({
25
28
  allowAll: "allow-all",
26
29
  denyAll: "deny-all",
27
30
  authenticated: "authenticated",
31
+ /** A client key issued for the MCP endpoint (protocol 1.20); its subject and attributes are the principal. */
32
+ clientKeys: "client-keys",
28
33
  allowCallers(first, ...rest) {
29
34
  return Object.freeze({ kind: "callers", callers: Object.freeze([first, ...rest]) });
30
35
  },
36
+ /** Admitted when any of the rules admits; for an MCP endpoint or one of its tools. */
37
+ anyOf(first, ...rest) {
38
+ return Object.freeze({ kind: "anyOf", acls: Object.freeze([first, ...rest]) });
39
+ },
31
40
  });
32
- /** Whether `x` is an access rule: one of the three words, or a non-empty callers rule. */
41
+ /** Whether `x` is an access rule: one of the words, a non-empty callers rule, or a non-empty `anyOf` of rules. */
33
42
  export function isAcl(x) {
34
- if (x === "allow-all" || x === "deny-all" || x === "authenticated")
43
+ if (x === "allow-all" || x === "deny-all" || x === "authenticated" || x === "client-keys")
35
44
  return true;
36
45
  if (typeof x !== "object" || x === null)
37
46
  return false;
38
47
  const rule = x;
48
+ if (rule.kind === "anyOf")
49
+ return Array.isArray(rule.acls) && rule.acls.length > 0 && rule.acls.every(isAcl);
39
50
  return rule.kind === "callers" && Array.isArray(rule.callers) && rule.callers.length > 0;
40
51
  }
52
+ /** Whether `acl` is one an HTTP endpoint or route can state: neither client keys nor `anyOf`, which are an MCP endpoint's. */
53
+ export function isEndpointAcl(acl) {
54
+ return acl !== "client-keys" && !(typeof acl === "object" && acl.kind === "anyOf");
55
+ }
41
56
  /** Thrown from a route handler to answer a status other than 200 with a plain-text message. */
42
57
  export class HttpProblem extends Error {
43
58
  status;
@@ -47,6 +62,21 @@ export class HttpProblem extends Error {
47
62
  this.status = status;
48
63
  }
49
64
  }
65
+ /**
66
+ * @internal An answer whose status and content type the handler chooses, its body as given: the
67
+ * directory store's objects, served under the content type each was kept with.
68
+ */
69
+ export class RawReply {
70
+ status;
71
+ contentType;
72
+ body;
73
+ constructor(status, contentType, body) {
74
+ this.status = status;
75
+ this.contentType = contentType;
76
+ this.body = body;
77
+ Object.freeze(this);
78
+ }
79
+ }
50
80
  const TEMPLATE = /^(\/(?:[^/{}]+|\{[A-Za-z_][A-Za-z0-9_]*\})?)*$/;
51
81
  export function parseTemplate(template) {
52
82
  if (typeof template !== "string" || !template.startsWith("/") || !TEMPLATE.test(template) || template.includes("//")) {
@@ -96,6 +126,29 @@ export const put = withBody("PUT");
96
126
  export const patch = withBody("PATCH");
97
127
  /** A DELETE route; `delete` is a reserved word, so `del`. */
98
128
  export const del = withBody("DELETE");
129
+ /**
130
+ * A watch as server-sent events, for an `sse` route: each row as `row` carrying the key and the row,
131
+ * each removal as `removed`, the marker as `caught-up`, and a watch that ended with a reason as a last
132
+ * event `ended` carrying it, so a page is told why.
133
+ */
134
+ export async function* sseEvents(watch) {
135
+ try {
136
+ for await (const event of watch) {
137
+ if (event.kind === "row")
138
+ yield sseEvent("row", { key: event.key, row: event.row });
139
+ else if (event.kind === "removed")
140
+ yield sseEvent("removed", { key: event.key });
141
+ else
142
+ yield sseEvent("caught-up", {});
143
+ }
144
+ }
145
+ catch (failure) {
146
+ if (failure instanceof Error && failure.name === "WatchEnded")
147
+ yield sseEvent("ended", { reason: failure.reason });
148
+ else
149
+ throw failure;
150
+ }
151
+ }
99
152
  export function sseEvent(name, value) {
100
153
  if (typeof name !== "string" || name === "" || /[\r\n]/.test(name))
101
154
  throw new TypeError(`an event name must be one non-empty line, not ${JSON.stringify(name)}`);
@@ -6,6 +6,8 @@ import { type Spec } from "../_proto/ankka/protocol/v1/discovery_pb.ts";
6
6
  * naming what declares them, rather than served wrong.
7
7
  */
8
8
  export declare function refusal(spec: Spec, sidecarProtocol: string): string | undefined;
9
+ /** The protocol version whose runtimes admit a process's endpoint at `/_ankka/storage`. */
10
+ export declare const STORAGE_SINCE: readonly [number, number];
9
11
  /** Every problem the sidecar has reported to this process, in order. */
10
12
  export declare const problems: string[];
11
13
  export declare function discoveryRoutes(router: ConnectRouter, spec: () => Spec, log?: (message: string) => void): void;
@@ -14,3 +16,13 @@ export declare const SOCKETS_SINCE: readonly [number, number];
14
16
  /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.9
15
17
  * does not know a socket route, and would serve it as a plain GET. */
16
18
  export declare function socketRefusal(spec: Spec, runtime: string): string | undefined;
19
+ /** The protocol version whose runtimes admit granted callers. */
20
+ export declare const GRANTS_SINCE: readonly [number, number];
21
+ /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.15
22
+ * does not know `Callers.granted`, and would admit nobody by it. */
23
+ export declare function grantsRefusal(spec: Spec, runtime: string): string | undefined;
24
+ /** The protocol version whose runtimes serve a process's MCP endpoint. */
25
+ export declare const MCP_SINCE: readonly [number, number];
26
+ /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.20
27
+ * does not know an MCP endpoint, and would serve nothing at its path. */
28
+ export declare function mcpRefusal(spec: Spec, runtime: string): string | undefined;
@@ -6,14 +6,60 @@ import { create } from "@bufbuild/protobuf";
6
6
  import { Discovery } from "../_proto/ankka/protocol/v1/discovery_pb.js";
7
7
  import { EmptySchema } from "../_proto/ankka/protocol/v1/payload_pb.js";
8
8
  import { PROTOCOL_VERSION } from "../spec.js";
9
- import { olderThanContracts, olderThanDeclaredQueries, olderThanStartPositions } from "../startFrom.js";
9
+ import { Kind } from "../_proto/ankka/protocol/v1/discovery_pb.js";
10
+ import { STORAGE_PATH } from "../storage.js";
11
+ import { olderThan, olderThanContracts, olderThanDeclaredQueries, olderThanStartPositions, olderThanViewStreams, olderThanWorkflowSources } from "../startFrom.js";
10
12
  /**
11
13
  * A sidecar older than 1.7 would ignore where a topic source starts and its version: a consumer
12
14
  * declared `latest` would read everything, and a raised version would rebuild nothing. Refused,
13
15
  * naming what declares them, rather than served wrong.
14
16
  */
15
17
  export function refusal(spec, sidecarProtocol) {
16
- return startPositionRefusal(spec, sidecarProtocol) ?? declaredQueryRefusal(spec, sidecarProtocol) ?? contractRefusal(spec, sidecarProtocol);
18
+ return (workflowSourceRefusal(spec, sidecarProtocol) ??
19
+ startPositionRefusal(spec, sidecarProtocol) ??
20
+ declaredQueryRefusal(spec, sidecarProtocol) ??
21
+ contractRefusal(spec, sidecarProtocol) ??
22
+ watchedQueryRefusal(spec, sidecarProtocol) ??
23
+ // Last: a reason an older sidecar has too is named first, by the version it needs.
24
+ replicationRefusal(spec, sidecarProtocol));
25
+ }
26
+ /**
27
+ * A sidecar older than 1.19 would host a replicated entity at one installation and say nothing, and
28
+ * would hand a consumer given mirrored changes only the changes written here. Refused at discovery,
29
+ * naming the components and the version.
30
+ */
31
+ function replicationRefusal(spec, sidecarProtocol) {
32
+ if (!olderThan(sidecarProtocol, [1, 19]))
33
+ return undefined;
34
+ const replicated = spec.components
35
+ .filter((c) => (c.detail.case === "eventSourced" && c.detail.value.replicated) ||
36
+ (c.detail.case === "consumer" && c.detail.value.mirroredChanges))
37
+ .map((c) => c.id);
38
+ if (replicated.length === 0)
39
+ return undefined;
40
+ return (`${replicated.join(", ")} declare replication, which needs protocol 1.19: ` +
41
+ `the sidecar speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.19 or later.`);
42
+ }
43
+ /**
44
+ * A sidecar older than 1.16 would not read a workflow as a source: a view or consumer that does would
45
+ * be handed nothing. Refused at discovery, naming them.
46
+ */
47
+ function workflowSourceRefusal(spec, sidecarProtocol) {
48
+ if (!olderThanWorkflowSources(sidecarProtocol))
49
+ return undefined;
50
+ const reading = spec.components
51
+ .filter((c) => {
52
+ const d = c.detail;
53
+ if (d.case !== "view" && d.case !== "consumer")
54
+ return false;
55
+ const sources = d.case === "view" ? [d.value.source, ...d.value.sources] : [d.value.source];
56
+ return sources.some((s) => s?.source.case === "component" && s.source.value.kind === Kind.WORKFLOW);
57
+ })
58
+ .map((c) => c.id);
59
+ if (reading.length === 0)
60
+ return undefined;
61
+ return (`${reading.join(", ")} read a workflow, which the sidecar does not know: ` +
62
+ `it speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.16 or later.`);
17
63
  }
18
64
  /**
19
65
  * A sidecar older than 1.14 would ignore a topic's contract, broker and parallel reading and a
@@ -39,6 +85,21 @@ function contractRefusal(spec, sidecarProtocol) {
39
85
  return (`${declaring.join(", ")} declare a topic's contract, broker or parallel reading, which the sidecar ignores: ` +
40
86
  `it speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.14 or later.`);
41
87
  }
88
+ /**
89
+ * A sidecar older than 1.17 would not know that a view's query may be watched, and a watch of it would
90
+ * be refused at its first asking. Refused at discovery, naming the views.
91
+ */
92
+ function watchedQueryRefusal(spec, sidecarProtocol) {
93
+ if (!olderThanViewStreams(sidecarProtocol))
94
+ return undefined;
95
+ const watching = spec.components
96
+ .filter((c) => c.detail.case === "view" && c.detail.value.declaredQueries.some((q) => q.watched))
97
+ .map((c) => c.id);
98
+ if (watching.length === 0)
99
+ return undefined;
100
+ return (`${watching.join(", ")} declare a watched query, which the sidecar does not know: ` +
101
+ `it speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.17 or later.`);
102
+ }
42
103
  /**
43
104
  * A sidecar older than 1.13 would ignore a view's declared queries, would read a keyed view as having
44
105
  * no source, and would refuse a version on a view that reads an entity as applying only to a topic.
@@ -77,14 +138,26 @@ function startPositionRefusal(spec, sidecarProtocol) {
77
138
  return (`${declaring.join(", ")} declare where a topic source starts or its version, which the sidecar ignores: ` +
78
139
  `it speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.7 or later.`);
79
140
  }
141
+ /** The protocol version whose runtimes admit a process's endpoint at `/_ankka/storage`. */
142
+ export const STORAGE_SINCE = [1, 21];
80
143
  /** Every problem the sidecar has reported to this process, in order. */
81
144
  export const problems = [];
82
145
  export function discoveryRoutes(router, spec, log = console.error) {
83
146
  router.service(Discovery, {
84
147
  async discover(info) {
85
148
  log(`ankka: discovery from sidecar (protocol ${info.protocolVersion}, runtime ${info.runtimeVersion})`);
86
- const answer = spec();
87
- const why = refusal(answer, info.protocolVersion) ?? socketRefusal(answer, info.protocolVersion);
149
+ let answer = spec();
150
+ // A runtime before 1.21 refuses a process's endpoint under /_ankka; the directory store's URLs then
151
+ // go unanswered, and nothing else of the store needs the runtime.
152
+ if (olderThan(info.protocolVersion, STORAGE_SINCE) && answer.endpoints.some((e) => e.prefix === STORAGE_PATH)) {
153
+ log(`ankka: the sidecar speaks protocol ${info.protocolVersion}; the directory store's URLs at ${STORAGE_PATH} need 1.21 and are not served`);
154
+ answer = { ...answer, endpoints: answer.endpoints.filter((e) => e.prefix !== STORAGE_PATH) };
155
+ }
156
+ const why = refusal(answer, info.protocolVersion) ??
157
+ socketRefusal(answer, info.protocolVersion) ??
158
+ grantsRefusal(answer, info.protocolVersion) ??
159
+ // Last: an older runtime is told the oldest thing it lacks first.
160
+ mcpRefusal(answer, info.protocolVersion);
88
161
  if (why !== undefined) {
89
162
  log(`ankka: ${why}`);
90
163
  problems.push(why);
@@ -113,3 +186,41 @@ export function socketRefusal(spec, runtime) {
113
186
  return undefined;
114
187
  return `this runtime speaks protocol ${runtime || "unknown"}; a socket route needs 1.9 (${routes.join(", ")})`;
115
188
  }
189
+ /** The protocol version whose runtimes admit granted callers. */
190
+ export const GRANTS_SINCE = [1, 15];
191
+ /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.15
192
+ * does not know `Callers.granted`, and would admit nobody by it. */
193
+ export function grantsRefusal(spec, runtime) {
194
+ const grants = (callers) => callers.some((m) => m.kind.case === "granted");
195
+ const endpoints = spec.endpoints
196
+ .filter((e) => grants(e.allowCallers) || e.routes.some((r) => grants(r.allowCallers)))
197
+ .map((e) => e.id);
198
+ // Another project's topic is 1.15's too: an older runtime would read it as this project's.
199
+ for (const c of spec.components) {
200
+ const d = c.detail;
201
+ const sources = d.case === "view" ? [d.value.source, ...d.value.sources] : d.case === "consumer" ? [d.value.source] : [];
202
+ const publishes = d.case === "consumer" && d.value.produces?.project !== undefined;
203
+ if (sources.some((s) => s?.project !== undefined) || publishes)
204
+ endpoints.push(c.id);
205
+ }
206
+ if (endpoints.length === 0)
207
+ return undefined;
208
+ const [major, minor] = runtime.split(".").map((part) => Number.parseInt(part, 10));
209
+ const speaks = Number.isFinite(major) && Number.isFinite(minor) ? [major, minor] : [0, 0];
210
+ if (speaks[0] > GRANTS_SINCE[0] || (speaks[0] === GRANTS_SINCE[0] && speaks[1] >= GRANTS_SINCE[1]))
211
+ return undefined;
212
+ return `this runtime speaks protocol ${runtime || "unknown"}; granted callers and another project's topics need 1.15 (${endpoints.join(", ")})`;
213
+ }
214
+ /** The protocol version whose runtimes serve a process's MCP endpoint. */
215
+ export const MCP_SINCE = [1, 20];
216
+ /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.20
217
+ * does not know an MCP endpoint, and would serve nothing at its path. */
218
+ export function mcpRefusal(spec, runtime) {
219
+ if (spec.mcpEndpoints.length === 0)
220
+ return undefined;
221
+ const [major, minor] = runtime.split(".").map((part) => Number.parseInt(part, 10));
222
+ const speaks = Number.isFinite(major) && Number.isFinite(minor) ? [major, minor] : [0, 0];
223
+ if (speaks[0] > MCP_SINCE[0] || (speaks[0] === MCP_SINCE[0] && speaks[1] >= MCP_SINCE[1]))
224
+ return undefined;
225
+ return `this runtime speaks protocol ${runtime || "unknown"}; an MCP endpoint needs 1.20 (${spec.mcpEndpoints.map((e) => e.path).join(", ")})`;
226
+ }
@@ -1,10 +1,13 @@
1
1
  import type { ConnectRouter } from "@connectrpc/connect";
2
2
  import { type MessageInitShape } from "@bufbuild/protobuf";
3
- import { type EventSourcedIn, type EventSourcedOut } from "../_proto/ankka/protocol/v1/event_sourced_pb.ts";
3
+ import { type ReplicationChangeSchema, type EventSourcedIn, type EventSourcedOut } from "../_proto/ankka/protocol/v1/event_sourced_pb.ts";
4
4
  import type { RetentionSchema } from "../_proto/ankka/protocol/v1/payload_pb.ts";
5
5
  import { type Retention } from "../effects/common.ts";
6
+ import type { ReplicationChange } from "../effects/eventSourced.ts";
6
7
  import type { ServerContext } from "./server.ts";
7
8
  type RetentionInit = MessageInitShape<typeof RetentionSchema>;
9
+ /** A replicated entity's handover or change to its replication set, as the reply carries it (1.19). */
10
+ export declare function replicationToProto(change: ReplicationChange | undefined): MessageInitShape<typeof ReplicationChangeSchema> | undefined;
8
11
  /** Retention as the protocol carries it; `undefined` when the effect asked for none. */
9
12
  export declare function retentionToProto(retention: Retention | null): RetentionInit | undefined;
10
13
  export declare function eventSourcedRoutes(router: ConnectRouter, ctx: ServerContext): void;
@@ -9,12 +9,20 @@
9
9
  import { create } from "@bufbuild/protobuf";
10
10
  import { EventSourced, EventSourcedOutSchema, } from "../_proto/ankka/protocol/v1/event_sourced_pb.js";
11
11
  import { codecFor } from "../codec.js";
12
- import { commandContext, metadataFromProto } from "../context.js";
12
+ import { commandContext, eventContext, metadataFromProto } from "../context.js";
13
13
  import { ErrorCode } from "../effects/common.js";
14
14
  import { binaryCodecs } from "../codec.js";
15
15
  import { errorCodeToProto } from "../kinds.js";
16
16
  import { materialiseEventSourced } from "../materialise.js";
17
17
  import { decodePayload, encodePayload } from "./payloads.js";
18
+ /** A replicated entity's handover or change to its replication set, as the reply carries it (1.19). */
19
+ export function replicationToProto(change) {
20
+ if (!change)
21
+ return undefined;
22
+ return change.kind === "hand-over"
23
+ ? { change: { case: "handOverTo", value: change.to } }
24
+ : { change: { case: "set", value: { add: [...change.add], remove: [...change.remove] } } };
25
+ }
18
26
  /** Retention as the protocol carries it; `undefined` when the effect asked for none. */
19
27
  export function retentionToProto(retention) {
20
28
  if (!retention)
@@ -43,10 +51,13 @@ export async function* handleEventSourced(requests, ctx) {
43
51
  let entityId = "";
44
52
  let state;
45
53
  let sequence = 0n;
54
+ // This installation, for a replicated entity (1.19): null on a service that names none.
55
+ let here = null;
46
56
  for await (const m of requests) {
47
57
  switch (m.message.case) {
48
58
  case "init": {
49
59
  const init = m.message.value;
60
+ here = init.installation || null;
50
61
  const found = ctx.registry.component(init.componentId);
51
62
  if (!found || found.kind !== "event-sourced") {
52
63
  yield failure(0n, { message: `no event sourced entity ${JSON.stringify(init.componentId)} is registered`, code: ErrorCode.NotFound });
@@ -80,7 +91,8 @@ export async function* handleEventSourced(requests, ctx) {
80
91
  }
81
92
  const ev = m.message.value;
82
93
  try {
83
- state = entity.applyEvent(state, decodePayload(registered.eventCodec, ev.payload));
94
+ const written = ev.installation || here;
95
+ state = entity.applyEvent(state, decodePayload(registered.eventCodec, ev.payload), eventContext(written, written === here, ev.sequence));
84
96
  sequence = ev.sequence;
85
97
  }
86
98
  catch (e) {
@@ -95,7 +107,7 @@ export async function* handleEventSourced(requests, ctx) {
95
107
  yield failure(m.message.value.id, { message: "a command arrived before Init", code: ErrorCode.Internal });
96
108
  return;
97
109
  }
98
- const result = await runCommand(registered, entity, entityId, state, sequence, m.message.value, ctx);
110
+ const result = await runCommand(registered, entity, entityId, state, sequence, m.message.value, ctx, here);
99
111
  yield result.out;
100
112
  if (result.newState !== undefined) {
101
113
  state = result.newState.state;
@@ -108,7 +120,7 @@ export async function* handleEventSourced(requests, ctx) {
108
120
  }
109
121
  }
110
122
  }
111
- async function runCommand(registered, entity, entityId, state, sequence, cmd, ctx) {
123
+ async function runCommand(registered, entity, entityId, state, sequence, cmd, ctx, here = null) {
112
124
  const handler = registered.handlers.get(cmd.name);
113
125
  if (!handler)
114
126
  return { out: failure(cmd.id, { message: `no handler ${JSON.stringify(cmd.name)} on ${registered.id}`, code: ErrorCode.NotFound }) };
@@ -120,7 +132,7 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
120
132
  return { out: failure(cmd.id, { message: `${registered.id}/${cmd.name}: the input could not be decoded: ${messageOf(e)}`, code: ErrorCode.BadRequest }) };
121
133
  }
122
134
  const metadata = metadataFromProto(cmd.metadata);
123
- const context = commandContext(registered.id, entityId, sequence, metadata);
135
+ const context = commandContext(registered.id, entityId, sequence, metadata, undefined, cmd.installation || null, cmd.atHome);
124
136
  let effect;
125
137
  entity._bindCommand(state, context, ctx.client.withMetadata(metadata));
126
138
  try {
@@ -137,7 +149,12 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
137
149
  return { out: failure(cmd.id, { message: `${registered.id}/${cmd.name} is a query and returned a persisting effect`, code: ErrorCode.Internal }) };
138
150
  }
139
151
  try {
140
- const m = materialiseEventSourced(effect, state, (s, e) => entity.applyEvent(s, e));
152
+ let applied = sequence;
153
+ const m = materialiseEventSourced(effect, state, (s, e) => {
154
+ applied += 1n;
155
+ return entity.applyEvent(s, e, eventContext(here, true, applied));
156
+ });
157
+ const replication = !m.error && effect.kind === "persist" ? replicationToProto(effect.replication) : undefined;
141
158
  const events = m.events.map((e) => encodePayload(registered.eventCodec, e));
142
159
  const replyCodec = handler.reply ? codecFor(handler.reply) : binaryCodecs.done;
143
160
  const outcome = m.error
@@ -154,6 +171,7 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
154
171
  retention: retentionToProto(m.retention),
155
172
  outcome,
156
173
  snapshot: cmd.snapshotRequested ? encodePayload(registered.stateCodec, m.newState) : undefined,
174
+ replication,
157
175
  },
158
176
  },
159
177
  });
@@ -1,6 +1,14 @@
1
1
  import type { ConnectRouter } from "@connectrpc/connect";
2
2
  import { type HttpRequest, type HttpReply, type SocketIn, type SocketOut, type StreamFrame } from "../_proto/ankka/protocol/v1/endpoint_pb.ts";
3
+ import { type Caller, type Principal } from "../context.ts";
4
+ import { type Schema } from "../schema.ts";
3
5
  import type { ServerContext } from "./server.ts";
6
+ /** The principal the sidecar verified, for a route or a tool call; never the credential. */
7
+ export declare function principalOf(req: Pick<HttpRequest, "principal">): Principal | null;
8
+ /** The caller the sidecar established; a sidecar that predates protocol 1.1 sends none, which reads as local. */
9
+ export declare function callerOf(req: Pick<HttpRequest, "caller">): Caller;
10
+ /** A path parameter as its declared schema: strings as they are, scalars through their text codec. */
11
+ export declare function parseParam(name: string, text: string, shape: Schema | undefined): unknown;
4
12
  export interface HttpDispatcher {
5
13
  handle(req: HttpRequest): Promise<HttpReply>;
6
14
  handleStream(req: HttpRequest): AsyncIterable<StreamFrame>;
@@ -11,7 +11,7 @@ import { Headers, Query, metadataFromProto, withRequest } from "../context.js";
11
11
  import { CommandError, ErrorCode, httpStatusOf } from "../effects/common.js";
12
12
  import { DecodingError } from "../json.js";
13
13
  import { errorCodeToProto } from "../kinds.js";
14
- import { HttpProblem } from "../routes.js";
14
+ import { HttpProblem, RawReply } from "../routes.js";
15
15
  import { done, resolve } from "../schema.js";
16
16
  import { SocketClosed, socketOf } from "../socket.js";
17
17
  import { AsyncQueue } from "./queue.js";
@@ -25,7 +25,8 @@ function failureReply(message) {
25
25
  function messageOf(e) {
26
26
  return e instanceof Error ? e.message : String(e);
27
27
  }
28
- function principalOf(req) {
28
+ /** The principal the sidecar verified, for a route or a tool call; never the credential. */
29
+ export function principalOf(req) {
29
30
  const p = req.principal;
30
31
  if (!p)
31
32
  return null;
@@ -40,19 +41,21 @@ function principalOf(req) {
40
41
  });
41
42
  }
42
43
  /** The caller the sidecar established; a sidecar that predates protocol 1.1 sends none, which reads as local. */
43
- function callerOf(req) {
44
+ export function callerOf(req) {
44
45
  const kind = req.caller?.kind;
45
46
  switch (kind?.case) {
46
47
  case "gateway":
47
48
  return Object.freeze({ kind: "gateway" });
48
49
  case "service":
49
50
  return Object.freeze({ kind: "service", project: kind.value.project, name: kind.value.name });
51
+ case "machine":
52
+ return Object.freeze({ kind: "machine", organization: kind.value.organization, name: kind.value.name });
50
53
  default:
51
54
  return Object.freeze({ kind: "local" });
52
55
  }
53
56
  }
54
57
  /** A path parameter as its declared schema: strings as they are, scalars through their text codec. */
55
- function parseParam(name, text, shape) {
58
+ export function parseParam(name, text, shape) {
56
59
  if (!shape)
57
60
  return text;
58
61
  const r = resolve(shape);
@@ -69,6 +72,9 @@ function parseParam(name, text, shape) {
69
72
  }
70
73
  }
71
74
  function encodeResult(route, result) {
75
+ if (result instanceof RawReply) {
76
+ return create(HttpReplySchema, { message: { case: "response", value: { status: result.status, contentType: result.contentType, body: result.body, headers: [] } } });
77
+ }
72
78
  if (result === undefined || result === done) {
73
79
  return create(HttpReplySchema, { message: { case: "response", value: { status: 204, contentType: "", body: new Uint8Array(), headers: [] } } });
74
80
  }
@@ -91,6 +97,8 @@ export function createHttpDispatcher(ctx) {
91
97
  if (!instance) {
92
98
  instance = new endpoint.cls();
93
99
  instance._bindClient(ctx.client);
100
+ if (ctx.storage)
101
+ instance.storage = ctx.storage;
94
102
  instances.set(endpoint.id, instance);
95
103
  }
96
104
  try {
@@ -0,0 +1,17 @@
1
+ import type { ConnectRouter } from "@connectrpc/connect";
2
+ import { type McpToolReply, type McpToolRequest } from "../_proto/ankka/protocol/v1/mcp_pb.ts";
3
+ import { type Shape } from "../codec.ts";
4
+ import type { ServerContext } from "./server.ts";
5
+ /** What a tool answered: its text, and the same as structured content when it is a JSON object. */
6
+ export interface ToolAnswer {
7
+ readonly text: string;
8
+ readonly structuredJson?: string;
9
+ }
10
+ /** A tool's result as text and structured content, encoded with its reply shape when it has one. */
11
+ export declare function answerOf(value: unknown, reply: Shape<unknown> | undefined): ToolAnswer;
12
+ export interface McpDispatcher {
13
+ invokeTool(req: McpToolRequest): Promise<McpToolReply>;
14
+ }
15
+ /** The behaviour behind `Mcp.InvokeTool`, over the registry's MCP endpoints. */
16
+ export declare function createMcpDispatcher(ctx: ServerContext): McpDispatcher;
17
+ export declare function mcpRoutes(router: ConnectRouter, ctx: ServerContext): void;
@@ -0,0 +1,157 @@
1
+ // A tool of one of this process's MCP endpoints (protocol 1.20). The platform answered the protocol,
2
+ // verified the credential and checked the arguments against the tool's schema; here the tool runs, as
3
+ // a route does in `http.ts`: the call in the async context (`this.request`, with the principal and the
4
+ // caller, never the credential), the run awaited, and its outcome answered as `ok`, `error` (the tool
5
+ // error a client is shown) or `failure` (this process could not run it at all).
6
+ //
7
+ // `createMcpDispatcher` is the whole behaviour; the Connect servicer and the unit tests both call it.
8
+ import { create } from "@bufbuild/protobuf";
9
+ import { Mcp, McpToolReplySchema } from "../_proto/ankka/protocol/v1/mcp_pb.js";
10
+ import { codecFor, isCodec } from "../codec.js";
11
+ import { Headers, Query, metadataFromProto, withRequest } from "../context.js";
12
+ import { CommandError, ErrorCode } from "../effects/common.js";
13
+ import { decodeJsonValue, reviver } from "../json.js";
14
+ import { errorCodeToProto } from "../kinds.js";
15
+ import { HttpProblem } from "../routes.js";
16
+ import { done } from "../schema.js";
17
+ import { callerOf, parseParam, principalOf } from "./http.js";
18
+ const utf8 = new TextEncoder();
19
+ const utf8Decoder = new TextDecoder();
20
+ function messageOf(e) {
21
+ return e instanceof Error ? e.message : String(e);
22
+ }
23
+ function isPlainObject(x) {
24
+ return typeof x === "object" && x !== null && !Array.isArray(x);
25
+ }
26
+ function structured(text) {
27
+ try {
28
+ return isPlainObject(JSON.parse(text)) ? { text, structuredJson: text } : { text };
29
+ }
30
+ catch {
31
+ return { text };
32
+ }
33
+ }
34
+ /** A tool's result as text and structured content, encoded with its reply shape when it has one. */
35
+ export function answerOf(value, reply) {
36
+ if (value === undefined || value === done)
37
+ return { text: "done" };
38
+ if (typeof value === "string" && (reply === undefined || codecFor(reply).contentType === "text/plain"))
39
+ return { text: value };
40
+ if (reply === undefined)
41
+ return structured(JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v)));
42
+ const codec = codecFor(reply);
43
+ const text = utf8Decoder.decode(codec.encode(value));
44
+ return codec.contentType === "application/json" ? structured(text) : { text };
45
+ }
46
+ /** An argument as a handler's input: through its codec when it has one, else its schema. */
47
+ function decodeArgument(shape, raw) {
48
+ if (isCodec(shape))
49
+ return shape.decode(utf8.encode(JSON.stringify(raw)));
50
+ return decodeJsonValue(shape, raw);
51
+ }
52
+ class ToolRefusal extends Error {
53
+ }
54
+ /** The behaviour behind `Mcp.InvokeTool`, over the registry's MCP endpoints. */
55
+ export function createMcpDispatcher(ctx) {
56
+ const instances = new Map();
57
+ function instanceOf(id, cls) {
58
+ let instance = instances.get(id);
59
+ if (!instance) {
60
+ instance = new cls();
61
+ instance._bindClient(ctx.client);
62
+ instances.set(id, instance);
63
+ }
64
+ return instance;
65
+ }
66
+ async function runHandler(run, args, self) {
67
+ const id = args[run.id];
68
+ if (typeof id !== "string" || id === "")
69
+ throw new ToolRefusal(`parameter '${run.id}': expected a non-empty string`);
70
+ const handler = run.handler;
71
+ let input;
72
+ if (run.input?.kind === "merge") {
73
+ const { [run.id]: _id, ...rest } = args;
74
+ input = decodeArgument(handler.input, rest);
75
+ }
76
+ else if (run.input?.kind === "argument") {
77
+ input = decodeArgument(handler.input, args[run.input.name]);
78
+ }
79
+ const client = self.client;
80
+ const calls = run.componentKind === "event-sourced"
81
+ ? client.forEventSourcedEntity(run.componentId, id)
82
+ : run.componentKind === "key-value"
83
+ ? client.forKeyValueEntity(run.componentId, id)
84
+ : client.forWorkflow(run.componentId, id);
85
+ const result = await calls.call(handler.name, handler.input, handler.reply).invoke(input);
86
+ return answerOf(result, handler.reply);
87
+ }
88
+ async function runRoute(run, args, call) {
89
+ const endpoint = ctx.registry.endpoint(run.endpointId);
90
+ if (!endpoint)
91
+ throw new Error(`the endpoint ${run.endpointId} is not registered`);
92
+ const route = run.route;
93
+ const params = {};
94
+ for (const name of run.paramNames) {
95
+ const raw = args[name];
96
+ if (typeof raw !== "string" && typeof raw !== "number")
97
+ throw new ToolRefusal(`parameter '${name}': expected a string`);
98
+ params[name] = parseParam(name, String(raw), route.paramShapes[name]);
99
+ }
100
+ const body = route.body ? codecFor(route.body).decode(utf8.encode(JSON.stringify(args.body ?? {}))) : undefined;
101
+ const request = Object.freeze({ ...call, params: Object.freeze(params) });
102
+ const instance = instanceOf(endpoint.id, endpoint.cls);
103
+ const result = await withRequest(request, () => route.run(instance, request, body));
104
+ return answerOf(result, route.reply);
105
+ }
106
+ async function run(endpoint, tool, req) {
107
+ const parsed = req.argumentsJson.trim() === "" ? {} : JSON.parse(req.argumentsJson, reviver);
108
+ if (!isPlainObject(parsed))
109
+ throw new ToolRefusal("the arguments must be an object");
110
+ const args = parsed;
111
+ const self = instanceOf(endpoint.id, endpoint.cls);
112
+ const call = Object.freeze({
113
+ params: Object.freeze({}),
114
+ query: new Query([]),
115
+ headers: new Headers([]),
116
+ principal: principalOf(req),
117
+ metadata: metadataFromProto(req.metadata),
118
+ caller: callerOf(req),
119
+ });
120
+ return withRequest(call, async () => {
121
+ switch (tool.run.kind) {
122
+ case "function":
123
+ return answerOf(await tool.run.run(decodeArgument(tool.run.input, args), self), tool.run.reply);
124
+ case "handler":
125
+ return runHandler(tool.run, args, self);
126
+ case "route":
127
+ return runRoute(tool.run, args, call);
128
+ }
129
+ });
130
+ }
131
+ return {
132
+ async invokeTool(req) {
133
+ const endpoint = ctx.registry.mcpEndpoint(req.endpointId);
134
+ const tool = endpoint?.tools.find((t) => t.name === req.tool);
135
+ if (!endpoint || !tool) {
136
+ return create(McpToolReplySchema, {
137
+ reply: { case: "failure", value: { commandId: 0n, error: { message: `no tool ${req.endpointId}/${req.tool} is registered`, code: errorCodeToProto(ErrorCode.NotFound) } } },
138
+ });
139
+ }
140
+ try {
141
+ const answer = await run(endpoint, tool, req);
142
+ return create(McpToolReplySchema, { reply: { case: "ok", value: { text: answer.text, ...(answer.structuredJson !== undefined ? { structuredJson: answer.structuredJson } : {}) } } });
143
+ }
144
+ catch (e) {
145
+ // A refusal, a decoding failure or a throw: the tool error the client is shown, which a model can act on.
146
+ if (!(e instanceof CommandError || e instanceof HttpProblem || e instanceof ToolRefusal)) {
147
+ ctx.log(`ankka: tool ${endpoint.path} ${tool.name} threw: ${e instanceof Error ? `${e.name}: ${e.message}` : String(e)}`);
148
+ }
149
+ return create(McpToolReplySchema, { reply: { case: "error", value: messageOf(e) } });
150
+ }
151
+ },
152
+ };
153
+ }
154
+ export function mcpRoutes(router, ctx) {
155
+ const dispatcher = createMcpDispatcher(ctx);
156
+ router.service(Mcp, { invokeTool: (req) => dispatcher.invokeTool(req) });
157
+ }