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.
- package/README.md +1 -0
- package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +910 -0
- package/dist/_proto/ankka/protocol/v1/client_pb.js +239 -23
- package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +10 -1
- package/dist/_proto/ankka/protocol/v1/consumer_pb.js +1 -1
- package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +177 -0
- package/dist/_proto/ankka/protocol/v1/discovery_pb.js +51 -36
- package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +26 -0
- package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +15 -10
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +86 -0
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +11 -1
- package/dist/_proto/ankka/protocol/v1/mcp_pb.d.ts +120 -0
- package/dist/_proto/ankka/protocol/v1/mcp_pb.js +29 -0
- package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +61 -0
- package/dist/_proto/ankka/protocol/v1/payload_pb.js +18 -1
- package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +7 -1
- package/dist/_proto/ankka/protocol/v1/view_pb.js +1 -1
- package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +12 -0
- package/dist/_proto/ankka/protocol/v1/wasm_pb.js +1 -1
- package/dist/agent.d.ts +10 -0
- package/dist/agent.js +20 -0
- package/dist/autonomous.d.ts +5 -0
- package/dist/autonomous.js +10 -0
- package/dist/client-keys.d.ts +89 -0
- package/dist/client-keys.js +182 -0
- package/dist/client.d.ts +96 -3
- package/dist/client.js +228 -6
- package/dist/consumer.d.ts +23 -1
- package/dist/consumer.js +25 -1
- package/dist/context.d.ts +25 -1
- package/dist/context.js +5 -2
- package/dist/contract.d.ts +4 -0
- package/dist/contract.js +2 -0
- package/dist/effects/common.d.ts +7 -0
- package/dist/effects/common.js +9 -1
- package/dist/effects/eventSourced.d.ts +26 -1
- package/dist/effects/eventSourced.js +58 -5
- package/dist/endpoint.d.ts +10 -0
- package/dist/endpoint.js +20 -0
- package/dist/eventSourcedEntity.d.ts +8 -3
- package/dist/graph.d.ts +14 -1
- package/dist/graph.js +17 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.js +8 -2
- package/dist/keyedView.d.ts +4 -1
- package/dist/keyedView.js +7 -1
- package/dist/kinds.js +2 -0
- package/dist/mcp-endpoint.d.ts +144 -0
- package/dist/mcp-endpoint.js +256 -0
- package/dist/routes.d.ts +46 -3
- package/dist/routes.js +56 -3
- package/dist/server/discovery.d.ts +12 -0
- package/dist/server/discovery.js +115 -4
- package/dist/server/eventSourced.d.ts +4 -1
- package/dist/server/eventSourced.js +24 -6
- package/dist/server/http.d.ts +8 -0
- package/dist/server/http.js +12 -4
- package/dist/server/mcp.d.ts +17 -0
- package/dist/server/mcp.js +157 -0
- package/dist/server/server.d.ts +11 -1
- package/dist/server/server.js +19 -0
- package/dist/server/stateless.js +5 -4
- package/dist/server/storageEndpoint.d.ts +14 -0
- package/dist/server/storageEndpoint.js +82 -0
- package/dist/service.d.ts +12 -1
- package/dist/service.js +73 -5
- package/dist/sigv4.d.ts +47 -0
- package/dist/sigv4.js +149 -0
- package/dist/spec.d.ts +8 -2
- package/dist/spec.js +62 -6
- package/dist/standing.d.ts +18 -0
- package/dist/standing.js +26 -0
- package/dist/startFrom.d.ts +4 -0
- package/dist/startFrom.js +8 -1
- package/dist/storage.d.ts +288 -0
- package/dist/storage.js +1081 -0
- package/dist/testkit/index.d.ts +1 -1
- package/dist/testkit/integration.d.ts +9 -0
- package/dist/testkit/integration.js +5 -1
- package/dist/testkit/kinds.d.ts +25 -9
- package/dist/testkit/kinds.js +53 -28
- package/dist/testkit/unit.d.ts +31 -3
- package/dist/testkit/unit.js +55 -11
- package/dist/timedAction.d.ts +10 -0
- package/dist/timedAction.js +20 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/view.d.ts +18 -3
- package/dist/view.js +13 -4
- package/dist/workflow.d.ts +8 -0
- package/dist/workflow.js +15 -0
- package/package.json +1 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// A service's client keys (protocol 1.20): credentials it issues for its MCP endpoint, each for one
|
|
2
|
+
// subject, with an expiry, an optional scope of tools and attributes a tool reads as the principal's
|
|
3
|
+
// claims. The runtime keeps them; this process sees a key once, in the answer to `issue`, and never
|
|
4
|
+
// again. Offered to endpoints, MCP endpoints, timed actions and agents, as the secret store is.
|
|
5
|
+
//
|
|
6
|
+
// const issued = await this.clientKeys.issue({ subject: "acme", expiry: Expiry.after({ days: 365 }), scope: ["list_jobs"] })
|
|
7
|
+
// issued.clientKey // shown here, and nowhere again
|
|
8
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
9
|
+
import { channelOf } from "./client.js";
|
|
10
|
+
import { metadataToProto } from "./context.js";
|
|
11
|
+
import { CommandError, ErrorCode } from "./effects/common.js";
|
|
12
|
+
import { errorCodeFromProto } from "./kinds.js";
|
|
13
|
+
import { PROTOCOL_VERSION } from "./spec.js";
|
|
14
|
+
import { Duration, Instant } from "./time.js";
|
|
15
|
+
/** The first protocol whose runtime keeps client keys; an older one answers `UNIMPLEMENTED`. */
|
|
16
|
+
export const CLIENT_KEYS_SINCE = "1.20";
|
|
17
|
+
export const Expiry = Object.freeze({
|
|
18
|
+
/** After a while from now: `Expiry.after({ days: 365 })`, or a `Duration`. */
|
|
19
|
+
after(by) {
|
|
20
|
+
const millis = by instanceof Duration
|
|
21
|
+
? by.toMillis()
|
|
22
|
+
: (by.days ?? 0) * 86_400_000 + (by.hours ?? 0) * 3_600_000 + (by.minutes ?? 0) * 60_000 + (by.seconds ?? 0) * 1_000 + (by.millis ?? 0);
|
|
23
|
+
if (!Number.isFinite(millis) || millis <= 0)
|
|
24
|
+
throw new TypeError(`a client key's expiry is a positive while, not ${millis}ms`);
|
|
25
|
+
return Object.freeze({ kind: "after", millis });
|
|
26
|
+
},
|
|
27
|
+
/** At an instant. */
|
|
28
|
+
at(when) {
|
|
29
|
+
const epochMillis = when instanceof Instant ? when.toEpochMilli() : when.getTime();
|
|
30
|
+
return Object.freeze({ kind: "at", epochMillis });
|
|
31
|
+
},
|
|
32
|
+
/** Never: admitted until it is revoked. */
|
|
33
|
+
never: Object.freeze({ kind: "never" }),
|
|
34
|
+
});
|
|
35
|
+
function refusalOf(error) {
|
|
36
|
+
return new CommandError({ message: error.message, code: errorCodeFromProto(error.code), details: { ...(error.details ?? {}) } });
|
|
37
|
+
}
|
|
38
|
+
function tooOld(failure) {
|
|
39
|
+
if (failure instanceof ConnectError && failure.code === Code.Unimplemented) {
|
|
40
|
+
throw new CommandError({
|
|
41
|
+
message: `the runtime beside this process is too old for client keys, which needs protocol ${CLIENT_KEYS_SINCE}; this process speaks ${PROTOCOL_VERSION}: ${failure.rawMessage}`,
|
|
42
|
+
code: ErrorCode.Internal,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
throw failure;
|
|
46
|
+
}
|
|
47
|
+
function instant(millis) {
|
|
48
|
+
return millis === undefined ? null : Instant.ofEpochMilli(Number(millis));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The service's client keys, kept by the runtime. A refusal is a `CommandError` with the runtime's
|
|
52
|
+
* code: `BAD_REQUEST` for a request that breaks a rule, `UNAVAILABLE` when the service's MCP
|
|
53
|
+
* endpoints admit no client key (there is then nothing to keep them), `NOT_FOUND` for an unknown id.
|
|
54
|
+
*/
|
|
55
|
+
export class ClientKeys {
|
|
56
|
+
#client;
|
|
57
|
+
/** The keys reached through `client`'s sidecar; with none, every call throws. */
|
|
58
|
+
constructor(client) {
|
|
59
|
+
this.#client = client;
|
|
60
|
+
}
|
|
61
|
+
#channel() {
|
|
62
|
+
const channel = this.#client ? channelOf(this.#client) : undefined;
|
|
63
|
+
if (!channel)
|
|
64
|
+
throw new Error("these client keys are not connected to a runtime; a unit test passes an InMemoryClientKeys");
|
|
65
|
+
return channel;
|
|
66
|
+
}
|
|
67
|
+
/** Issues a key; the answer is the one place the key is ever shown. */
|
|
68
|
+
async issue(request) {
|
|
69
|
+
const { stub, metadata } = this.#channel();
|
|
70
|
+
const expiry = request.expiry;
|
|
71
|
+
const reply = await stub()
|
|
72
|
+
.issueClientKey({
|
|
73
|
+
subject: request.subject,
|
|
74
|
+
expiry: expiry.kind === "after"
|
|
75
|
+
? { case: "afterMillis", value: BigInt(expiry.millis) }
|
|
76
|
+
: expiry.kind === "at"
|
|
77
|
+
? { case: "atEpochMillis", value: BigInt(expiry.epochMillis) }
|
|
78
|
+
: { case: "never", value: true },
|
|
79
|
+
...(request.endpoint !== undefined ? { endpoint: request.endpoint } : {}),
|
|
80
|
+
scope: [...(request.scope ?? [])],
|
|
81
|
+
attributes: { ...(request.attributes ?? {}) },
|
|
82
|
+
...(request.label !== undefined ? { label: request.label } : {}),
|
|
83
|
+
metadata: metadataToProto(metadata),
|
|
84
|
+
})
|
|
85
|
+
.catch(tooOld);
|
|
86
|
+
switch (reply.reply.case) {
|
|
87
|
+
case "issued":
|
|
88
|
+
return Object.freeze({ id: reply.reply.value.id, clientKey: reply.reply.value.clientKey });
|
|
89
|
+
case "error":
|
|
90
|
+
throw refusalOf(reply.reply.value);
|
|
91
|
+
default:
|
|
92
|
+
throw new CommandError({ message: "the runtime answered an issue with nothing", code: ErrorCode.Internal });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Every client key of the service, revoked ones included; never a key itself. */
|
|
96
|
+
async list() {
|
|
97
|
+
const { stub, metadata } = this.#channel();
|
|
98
|
+
const reply = await stub().listClientKeys({ metadata: metadataToProto(metadata) }).catch(tooOld);
|
|
99
|
+
if (reply.error)
|
|
100
|
+
throw refusalOf(reply.error);
|
|
101
|
+
return reply.keys.map((k) => Object.freeze({
|
|
102
|
+
id: k.id,
|
|
103
|
+
subject: k.subject,
|
|
104
|
+
attributes: Object.freeze({ ...k.attributes }),
|
|
105
|
+
label: k.label ?? null,
|
|
106
|
+
endpoint: k.endpoint,
|
|
107
|
+
scope: Object.freeze([...k.scope]),
|
|
108
|
+
issuedAt: Instant.ofEpochMilli(Number(k.issuedAtMillis)),
|
|
109
|
+
expiresAt: instant(k.expiresAtMillis),
|
|
110
|
+
lastUsed: instant(k.lastUsedMillis),
|
|
111
|
+
revokedAt: instant(k.revokedAtMillis),
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
/** Revokes a key: refused on its next request on every instance. Revoking twice is not an error. */
|
|
115
|
+
async revoke(id) {
|
|
116
|
+
const { stub, metadata } = this.#channel();
|
|
117
|
+
const reply = await stub().revokeClientKey({ id, metadata: metadataToProto(metadata) }).catch(tooOld);
|
|
118
|
+
if (reply.error)
|
|
119
|
+
throw refusalOf(reply.error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Client keys for unit tests: a map, issuing keys that look like the runtime's and applying its rules. */
|
|
123
|
+
export class InMemoryClientKeys extends ClientKeys {
|
|
124
|
+
#keys = new Map();
|
|
125
|
+
#next = 0;
|
|
126
|
+
constructor() {
|
|
127
|
+
super(undefined);
|
|
128
|
+
}
|
|
129
|
+
/** The key issued under `id`, which a test may present; the runtime shows it only once. */
|
|
130
|
+
keyOf(id) {
|
|
131
|
+
return this.#keys.get(id)?.clientKey;
|
|
132
|
+
}
|
|
133
|
+
async issue(request) {
|
|
134
|
+
if (typeof request.subject !== "string" || request.subject.trim() === "") {
|
|
135
|
+
throw new CommandError({ message: "a client key needs a subject", code: ErrorCode.BadRequest });
|
|
136
|
+
}
|
|
137
|
+
const attributes = request.attributes ?? {};
|
|
138
|
+
if (Object.keys(attributes).length > 16)
|
|
139
|
+
throw new CommandError({ message: "a client key carries at most 16 attributes", code: ErrorCode.BadRequest });
|
|
140
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
141
|
+
if (name.length > 256 || value.length > 256)
|
|
142
|
+
throw new CommandError({ message: `attribute '${name.slice(0, 40)}' is longer than 256 characters`, code: ErrorCode.BadRequest });
|
|
143
|
+
}
|
|
144
|
+
this.#next += 1;
|
|
145
|
+
const id = `ck_${this.#next}`;
|
|
146
|
+
const clientKey = `ak_test_${id}_${Math.random().toString(36).slice(2)}`;
|
|
147
|
+
const now = Instant.now();
|
|
148
|
+
const expiry = request.expiry;
|
|
149
|
+
const expiresAt = expiry.kind === "after" ? Instant.ofEpochMilli(now.toEpochMilli() + expiry.millis) : expiry.kind === "at" ? Instant.ofEpochMilli(expiry.epochMillis) : null;
|
|
150
|
+
const summary = Object.freeze({
|
|
151
|
+
id,
|
|
152
|
+
subject: request.subject,
|
|
153
|
+
attributes: Object.freeze({ ...attributes }),
|
|
154
|
+
label: request.label ?? null,
|
|
155
|
+
endpoint: request.endpoint ?? "",
|
|
156
|
+
scope: Object.freeze([...(request.scope ?? [])]),
|
|
157
|
+
issuedAt: now,
|
|
158
|
+
expiresAt,
|
|
159
|
+
lastUsed: null,
|
|
160
|
+
revokedAt: null,
|
|
161
|
+
});
|
|
162
|
+
this.#keys.set(id, { summary, clientKey });
|
|
163
|
+
return Object.freeze({ id, clientKey });
|
|
164
|
+
}
|
|
165
|
+
async list() {
|
|
166
|
+
return [...this.#keys.values()].map((k) => k.summary);
|
|
167
|
+
}
|
|
168
|
+
async revoke(id) {
|
|
169
|
+
const held = this.#keys.get(id);
|
|
170
|
+
if (!held)
|
|
171
|
+
throw new CommandError({ message: `no client key '${id}'`, code: ErrorCode.NotFound });
|
|
172
|
+
if (held.summary.revokedAt === null)
|
|
173
|
+
this.#keys.set(id, { ...held, summary: Object.freeze({ ...held.summary, revokedAt: Instant.now() }) });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* @internal The client keys reached through `client`'s sidecar. Not a member of the client, which an
|
|
178
|
+
* entity holds too: only the component kinds that may issue keys call this.
|
|
179
|
+
*/
|
|
180
|
+
export function clientKeysFor(client) {
|
|
181
|
+
return new ClientKeys(client);
|
|
182
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Client as ConnectClient, type Transport } from "@connectrpc/connect";
|
|
2
2
|
import { Client } from "./_proto/ankka/protocol/v1/client_pb.ts";
|
|
3
3
|
import { jsonCodec, type Shape } from "./codec.ts";
|
|
4
|
-
import { type Metadata } from "./effects/common.ts";
|
|
4
|
+
import { CommandError, type Metadata } from "./effects/common.ts";
|
|
5
5
|
import type { HandlerRef } from "./handlers.ts";
|
|
6
6
|
import { type ComponentKind } from "./kinds.ts";
|
|
7
7
|
import { Duration } from "./time.ts";
|
|
@@ -19,15 +19,38 @@ interface Connection {
|
|
|
19
19
|
transport: Transport | undefined;
|
|
20
20
|
stub: ConnectClient<typeof Client> | undefined;
|
|
21
21
|
}
|
|
22
|
+
/** The first protocol whose runtime can wait for a workflow's end; an older one answers `UNIMPLEMENTED`. */
|
|
23
|
+
export declare const AWAIT_SINCE = "1.18";
|
|
24
|
+
/** One part of a wait served as a stream: the wait goes on, or how it ended. */
|
|
25
|
+
export type AwaitPart<S> = {
|
|
26
|
+
readonly kind: "heartbeat";
|
|
27
|
+
} | {
|
|
28
|
+
readonly kind: "ended";
|
|
29
|
+
readonly state: S;
|
|
30
|
+
} | {
|
|
31
|
+
readonly kind: "failed";
|
|
32
|
+
readonly error: CommandError;
|
|
33
|
+
} | {
|
|
34
|
+
readonly kind: "timed-out";
|
|
35
|
+
readonly message: string;
|
|
36
|
+
};
|
|
37
|
+
/** A part as one line of JSON, which is what an `sse` route yields for it. */
|
|
38
|
+
export declare function awaitPartJson<S>(part: AwaitPart<S>): string;
|
|
22
39
|
/** One call, ready to invoke or stream. */
|
|
23
40
|
export declare class Invocation<I, R> {
|
|
24
41
|
#private;
|
|
25
|
-
constructor(connection: Connection, metadata: Metadata, kind: ComponentKind, componentId: string, entityId: string, name: string, input: Shape<I> | undefined, reply: Shape<R> | undefined);
|
|
42
|
+
constructor(connection: Connection, metadata: Metadata, kind: ComponentKind, componentId: string, entityId: string, name: string, input: Shape<I> | undefined, reply: Shape<R> | undefined, state?: Shape<unknown>);
|
|
26
43
|
/**
|
|
27
44
|
* Invokes and decodes the reply; a refusal rejects with `CommandError`. An agent's turn that waits for a
|
|
28
45
|
* person rejects with `ApprovalAwaited`; `ask` answers it instead.
|
|
29
46
|
*/
|
|
30
47
|
invoke(input?: I): Promise<R>;
|
|
48
|
+
/**
|
|
49
|
+
* Sends this command to the workflow and then waits for its end, as one call: answered with the state it
|
|
50
|
+
* ended with, within `timeoutMillis` of the command being sent. A refusal of the command rejects at once and
|
|
51
|
+
* no wait begins; the command's own reply is not kept. `state` decodes it, unless the workflow class did.
|
|
52
|
+
*/
|
|
53
|
+
thenAwaitEnd<S>(timeoutMillis: number, state?: Shape<S>): AwaitingInvocation<I, S>;
|
|
31
54
|
/** Calls an agent's handler for its outcome: the model's answer, or the approval requests the turn waits on. */
|
|
32
55
|
ask(input?: I): Promise<AgentOutcome<R>>;
|
|
33
56
|
/**
|
|
@@ -43,12 +66,31 @@ export declare class Invocation<I, R> {
|
|
|
43
66
|
kind: "awaiting-approval";
|
|
44
67
|
}>>;
|
|
45
68
|
}
|
|
69
|
+
/** A command to a workflow followed by a wait for its end, with one deadline from the send. */
|
|
70
|
+
export declare class AwaitingInvocation<I, S> {
|
|
71
|
+
#private;
|
|
72
|
+
constructor(timeoutMillis: number, send: (input: I | undefined) => Promise<unknown>, wait: (leftMillis: number) => Promise<S>);
|
|
73
|
+
/** Sends the command and waits for the end; rejects as `Calls.awaitEnd` does. */
|
|
74
|
+
invoke(input?: I): Promise<S>;
|
|
75
|
+
}
|
|
46
76
|
/** Calls on one component instance, by wire name. */
|
|
47
77
|
export declare class Calls {
|
|
48
78
|
#private;
|
|
49
|
-
constructor(connection: Connection, metadata: Metadata, kind: ComponentKind, componentId: string, entityId: string);
|
|
79
|
+
constructor(connection: Connection, metadata: Metadata, kind: ComponentKind, componentId: string, entityId: string, state?: Shape<unknown>);
|
|
50
80
|
/** `call("add-item", LineItem, Done)`: the wire name, the input shape (omit for none) and the reply shape (omit for `done`). */
|
|
51
81
|
call<I = undefined, R = unknown>(name: string, input?: Shape<I>, reply?: Shape<R>): Invocation<I, R>;
|
|
82
|
+
/**
|
|
83
|
+
* Waits for this workflow's end, for at most `timeoutMillis`, and resolves with the state it ended with — at
|
|
84
|
+
* once when it already has. A workflow that failed or was deleted rejects with `CommandError` whose code is
|
|
85
|
+
* `WORKFLOW_FAILED` and whose `details` hold `step`, `reason` and `deleted`; a wait not answered in time
|
|
86
|
+
* rejects with `TIMEOUT` and the workflow runs on. A paused workflow has not ended. There is no default.
|
|
87
|
+
*/
|
|
88
|
+
awaitEnd<S>(timeoutMillis: number, state?: Shape<S>): Promise<S>;
|
|
89
|
+
/**
|
|
90
|
+
* The same wait as a stream, for an `sse` route that must outlast the idle timeout: a heartbeat while it goes
|
|
91
|
+
* on, then exactly one `ended`, `failed` or `timed-out` part. `awaitPartJson` renders a part as a line.
|
|
92
|
+
*/
|
|
93
|
+
awaitEndParts<S>(timeoutMillis: number, state?: Shape<S>): AsyncIterable<AwaitPart<S>>;
|
|
52
94
|
}
|
|
53
95
|
/** Calls on one component instance through its class's handler table, fully typed. */
|
|
54
96
|
export declare class TypedCalls<C> {
|
|
@@ -56,7 +98,41 @@ export declare class TypedCalls<C> {
|
|
|
56
98
|
constructor(calls: Calls);
|
|
57
99
|
/** `call(ShoppingCartEntity.handlers.addItem)`: the handler's wire name and shapes come from the declaration. */
|
|
58
100
|
call<I, R>(handler: HandlerRef<C, I, R, any>): Invocation<I, R>;
|
|
101
|
+
/** For a workflow: waits for its end and resolves with its state, decoded as the class declares it. */
|
|
102
|
+
awaitEnd<S = StateOf<C>>(timeoutMillis: number): Promise<S>;
|
|
103
|
+
/** For a workflow: the same wait as a stream of parts. */
|
|
104
|
+
awaitEndParts<S = StateOf<C>>(timeoutMillis: number): AsyncIterable<AwaitPart<S>>;
|
|
105
|
+
}
|
|
106
|
+
/** The first protocol in which a view's query can be streamed and watched. */
|
|
107
|
+
export declare const VIEW_STREAMS_SINCE = "1.17";
|
|
108
|
+
/** What a watch of a view gives: a row as the query sees it now, a removal of one it gave, or the caught-up marker. */
|
|
109
|
+
export type WatchEvent<Row> = {
|
|
110
|
+
readonly kind: "row";
|
|
111
|
+
readonly key: string;
|
|
112
|
+
readonly row: Row;
|
|
113
|
+
} | {
|
|
114
|
+
readonly kind: "removed";
|
|
115
|
+
readonly key: string;
|
|
116
|
+
} | {
|
|
117
|
+
readonly kind: "caughtUp";
|
|
118
|
+
};
|
|
119
|
+
/** What a watch does past its unread bound: none slows the view. */
|
|
120
|
+
export type Overflow = "dropHead" | "dropTail" | "dropNew" | "dropAll" | "fail";
|
|
121
|
+
export interface WatchOptions {
|
|
122
|
+
/** How many unread rows the watch holds, one per key; the instance's own bound when absent. */
|
|
123
|
+
readonly unread?: number;
|
|
124
|
+
/** What it does past that bound; `dropHead`, the row changed longest ago, when absent. */
|
|
125
|
+
readonly overflow?: Overflow;
|
|
59
126
|
}
|
|
127
|
+
/** A watch ended for `reason`: `rebuilt`, `instance-stopping`, `listener-lost` or `unread`. Watch again for the rows now. */
|
|
128
|
+
export declare class WatchEnded extends Error {
|
|
129
|
+
readonly reason: string;
|
|
130
|
+
constructor(reason: string);
|
|
131
|
+
}
|
|
132
|
+
/** A workflow instance's state type, read from its `state` getter. */
|
|
133
|
+
type StateOf<C> = C extends {
|
|
134
|
+
readonly state: infer S;
|
|
135
|
+
} ? S : unknown;
|
|
60
136
|
/** View queries: rows come back as a JSON array decoded with the row shape. */
|
|
61
137
|
export declare class Views {
|
|
62
138
|
#private;
|
|
@@ -69,6 +145,23 @@ export declare class Views {
|
|
|
69
145
|
* `limit` rows, 1000 when it is not given, in the statement's own order.
|
|
70
146
|
*/
|
|
71
147
|
ask<Row>(viewId: string, name: string, values: Readonly<Record<string, string>>, row: Shape<Row>, limit?: number): Promise<Row[]>;
|
|
148
|
+
/**
|
|
149
|
+
* The rows of `all` or one of a view's declared queries as the database yields them: nothing
|
|
150
|
+
* collected, and no limit unless one is given. A statement the database ends, or a reader that
|
|
151
|
+
* takes nothing for its timeout, throws.
|
|
152
|
+
*/
|
|
153
|
+
stream<Row>(viewId: string, name: string, values: Readonly<Record<string, string>>, row: Shape<Row>, options?: {
|
|
154
|
+
readonly limit?: number;
|
|
155
|
+
}): AsyncIterable<Row>;
|
|
156
|
+
/**
|
|
157
|
+
* A watch of one of a view's declared queries, declared `{ watched: true }`: every row it matches
|
|
158
|
+
* now, then `caughtUp`, then each row as it is written and matches and a `removed` for each given row
|
|
159
|
+
* that stops matching. Throws `WatchEnded` when it ends with a reason. `unread` bounds the rows held
|
|
160
|
+
* for a reader that has not read them, and `overflow` says what happens past that bound.
|
|
161
|
+
*/
|
|
162
|
+
watch<Row>(viewId: string, name: string, values: Readonly<Record<string, string>>, row: Shape<Row>, options?: WatchOptions): AsyncIterable<WatchEvent<Row>>;
|
|
163
|
+
/** A watch of one row by its key: the row now, then `caughtUp`, then each version and a `removed` when it is deleted. */
|
|
164
|
+
watchRow<Row>(viewId: string, key: string, row: Shape<Row>, options?: WatchOptions): AsyncIterable<WatchEvent<Row>>;
|
|
72
165
|
}
|
|
73
166
|
/** What a timer fires: a component class and one of its handlers, or the same by name. */
|
|
74
167
|
export type TimerTarget = {
|
package/dist/client.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// so the sidecar records the call as a child span.
|
|
6
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";
|
|
@@ -24,7 +24,28 @@ function stubOf(connection) {
|
|
|
24
24
|
return connection.stub;
|
|
25
25
|
}
|
|
26
26
|
function errorOf(error) {
|
|
27
|
-
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));
|
|
28
49
|
}
|
|
29
50
|
function inputPayload(shape, input) {
|
|
30
51
|
if (shape === undefined) {
|
|
@@ -88,7 +109,9 @@ export class Invocation {
|
|
|
88
109
|
#name;
|
|
89
110
|
#input;
|
|
90
111
|
#reply;
|
|
91
|
-
|
|
112
|
+
#state;
|
|
113
|
+
constructor(connection, metadata, kind, componentId, entityId, name, input, reply, state) {
|
|
114
|
+
this.#state = state;
|
|
92
115
|
this.#connection = connection;
|
|
93
116
|
this.#metadata = metadata;
|
|
94
117
|
this.#kind = kind;
|
|
@@ -118,6 +141,16 @@ export class Invocation {
|
|
|
118
141
|
throw new ApprovalAwaited(outcome.requests);
|
|
119
142
|
return outcome.value;
|
|
120
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
|
+
}
|
|
121
154
|
/** Calls an agent's handler for its outcome: the model's answer, or the approval requests the turn waits on. */
|
|
122
155
|
async ask(input) {
|
|
123
156
|
return outcomeOf(this.#reply, await stubOf(this.#connection).invoke(this.#request(input)));
|
|
@@ -156,6 +189,32 @@ export class Invocation {
|
|
|
156
189
|
}
|
|
157
190
|
}
|
|
158
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
|
+
};
|
|
159
218
|
/** Calls on one component instance, by wire name. */
|
|
160
219
|
export class Calls {
|
|
161
220
|
#connection;
|
|
@@ -163,16 +222,67 @@ export class Calls {
|
|
|
163
222
|
#kind;
|
|
164
223
|
#componentId;
|
|
165
224
|
#entityId;
|
|
166
|
-
|
|
225
|
+
#state;
|
|
226
|
+
constructor(connection, metadata, kind, componentId, entityId, state) {
|
|
167
227
|
this.#connection = connection;
|
|
168
228
|
this.#metadata = metadata;
|
|
169
229
|
this.#kind = kind;
|
|
170
230
|
this.#componentId = componentId;
|
|
171
231
|
this.#entityId = entityId;
|
|
232
|
+
this.#state = state;
|
|
172
233
|
}
|
|
173
234
|
/** `call("add-item", LineItem, Done)`: the wire name, the input shape (omit for none) and the reply shape (omit for `done`). */
|
|
174
235
|
call(name, input, reply) {
|
|
175
|
-
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
|
+
}
|
|
176
286
|
}
|
|
177
287
|
}
|
|
178
288
|
/** Calls on one component instance through its class's handler table, fully typed. */
|
|
@@ -185,6 +295,39 @@ export class TypedCalls {
|
|
|
185
295
|
call(handler) {
|
|
186
296
|
return this.#calls.call(handler.name, handler.input, handler.reply);
|
|
187
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
|
+
}
|
|
188
331
|
}
|
|
189
332
|
/** View queries: rows come back as a JSON array decoded with the row shape. */
|
|
190
333
|
export class Views {
|
|
@@ -219,6 +362,83 @@ export class Views {
|
|
|
219
362
|
});
|
|
220
363
|
return Views.#rows(answer, row);
|
|
221
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
|
+
}
|
|
222
442
|
static #rows(answer, row) {
|
|
223
443
|
switch (answer.result.case) {
|
|
224
444
|
case "rows": {
|
|
@@ -517,7 +737,9 @@ export class ComponentClient {
|
|
|
517
737
|
}
|
|
518
738
|
/** Typed calls through a component class's handler table: `client.of(ShoppingCartEntity, cartId).call(ShoppingCartEntity.handlers.addItem).invoke(item)`. */
|
|
519
739
|
of(component, entityId) {
|
|
520
|
-
|
|
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));
|
|
521
743
|
}
|
|
522
744
|
}
|
|
523
745
|
/** A client for unit tests: every call throws, naming what was attempted. */
|