@north-light/sdk 0.0.0 → 0.3.415

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 (121) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +187 -2
  3. package/dist/client.d.ts +176 -0
  4. package/dist/client.js +582 -0
  5. package/dist/crtr.d.ts +24 -0
  6. package/dist/crtr.js +50 -0
  7. package/dist/env-command.d.ts +15 -0
  8. package/dist/env-command.js +138 -0
  9. package/dist/error-codes.d.ts +7 -0
  10. package/dist/error-codes.js +43 -0
  11. package/dist/errors.d.ts +72 -0
  12. package/dist/errors.js +101 -0
  13. package/dist/index.d.ts +41 -0
  14. package/dist/index.js +17 -0
  15. package/dist/keygen-cli.d.ts +2 -0
  16. package/dist/keygen-cli.js +11 -0
  17. package/dist/keygen-command.d.ts +6 -0
  18. package/dist/keygen-command.js +131 -0
  19. package/dist/oauth/index.d.ts +235 -0
  20. package/dist/oauth/index.js +621 -0
  21. package/dist/oauth/keygen.d.ts +18 -0
  22. package/dist/oauth/keygen.js +46 -0
  23. package/dist/oauth/refusal.d.ts +32 -0
  24. package/dist/oauth/refusal.js +17 -0
  25. package/dist/resources/activity.d.ts +22 -0
  26. package/dist/resources/activity.js +43 -0
  27. package/dist/resources/attachments.d.ts +15 -0
  28. package/dist/resources/attachments.js +13 -0
  29. package/dist/resources/bash.d.ts +9 -0
  30. package/dist/resources/bash.js +15 -0
  31. package/dist/resources/canvas/history.d.ts +11 -0
  32. package/dist/resources/canvas/history.js +20 -0
  33. package/dist/resources/canvas.d.ts +19 -0
  34. package/dist/resources/canvas.js +42 -0
  35. package/dist/resources/crons.d.ts +15 -0
  36. package/dist/resources/crons.js +33 -0
  37. package/dist/resources/custom-objects.d.ts +20 -0
  38. package/dist/resources/custom-objects.js +84 -0
  39. package/dist/resources/files.d.ts +34 -0
  40. package/dist/resources/files.js +34 -0
  41. package/dist/resources/follower.d.ts +71 -0
  42. package/dist/resources/follower.js +202 -0
  43. package/dist/resources/forward.d.ts +7 -0
  44. package/dist/resources/forward.js +67 -0
  45. package/dist/resources/grant-watcher.d.ts +57 -0
  46. package/dist/resources/grant-watcher.js +100 -0
  47. package/dist/resources/human/inbox.d.ts +14 -0
  48. package/dist/resources/human/inbox.js +30 -0
  49. package/dist/resources/human/requests.d.ts +13 -0
  50. package/dist/resources/human/requests.js +26 -0
  51. package/dist/resources/human.d.ts +8 -0
  52. package/dist/resources/human.js +10 -0
  53. package/dist/resources/identifiers.d.ts +8 -0
  54. package/dist/resources/identifiers.js +33 -0
  55. package/dist/resources/memory.d.ts +69 -0
  56. package/dist/resources/memory.js +30 -0
  57. package/dist/resources/models/config.d.ts +8 -0
  58. package/dist/resources/models/config.js +9 -0
  59. package/dist/resources/models/credentials.d.ts +10 -0
  60. package/dist/resources/models/credentials.js +17 -0
  61. package/dist/resources/models.d.ts +8 -0
  62. package/dist/resources/models.js +10 -0
  63. package/dist/resources/node-stream.d.ts +34 -0
  64. package/dist/resources/node-stream.js +176 -0
  65. package/dist/resources/nodes/jobs.d.ts +9 -0
  66. package/dist/resources/nodes/jobs.js +14 -0
  67. package/dist/resources/nodes/result.d.ts +8 -0
  68. package/dist/resources/nodes/result.js +11 -0
  69. package/dist/resources/nodes/worktree.d.ts +9 -0
  70. package/dist/resources/nodes/worktree.js +14 -0
  71. package/dist/resources/nodes.d.ts +23 -0
  72. package/dist/resources/nodes.js +47 -0
  73. package/dist/resources/providers.d.ts +51 -0
  74. package/dist/resources/providers.js +15 -0
  75. package/dist/resources/questions.d.ts +49 -0
  76. package/dist/resources/questions.js +21 -0
  77. package/dist/resources/request.d.ts +12 -0
  78. package/dist/resources/request.js +50 -0
  79. package/dist/resources/run-notice.d.ts +34 -0
  80. package/dist/resources/run-notice.js +67 -0
  81. package/dist/resources/run-reply.d.ts +79 -0
  82. package/dist/resources/run-reply.js +93 -0
  83. package/dist/resources/run-stream.d.ts +65 -0
  84. package/dist/resources/run-stream.js +243 -0
  85. package/dist/resources/run-watcher.d.ts +98 -0
  86. package/dist/resources/run-watcher.js +162 -0
  87. package/dist/resources/runs.d.ts +199 -0
  88. package/dist/resources/runs.js +228 -0
  89. package/dist/resources/shares.d.ts +40 -0
  90. package/dist/resources/shares.js +19 -0
  91. package/dist/resources/structured-requests.d.ts +14 -0
  92. package/dist/resources/structured-requests.js +19 -0
  93. package/dist/resources/uploads.d.ts +27 -0
  94. package/dist/resources/uploads.js +11 -0
  95. package/dist/schema.d.ts +7 -0
  96. package/dist/schema.js +10 -0
  97. package/dist/stores/postgres.d.ts +32 -0
  98. package/dist/stores/postgres.js +92 -0
  99. package/dist/testing/clock.d.ts +7 -0
  100. package/dist/testing/clock.js +12 -0
  101. package/dist/testing/cloud.d.ts +228 -0
  102. package/dist/testing/cloud.js +914 -0
  103. package/dist/testing/conformance/index.d.ts +2 -0
  104. package/dist/testing/conformance/index.js +3 -0
  105. package/dist/testing/conformance/keyed-write.d.ts +43 -0
  106. package/dist/testing/conformance/keyed-write.js +124 -0
  107. package/dist/testing/index.d.ts +7 -0
  108. package/dist/testing/index.js +4 -0
  109. package/dist/testing/notices.d.ts +76 -0
  110. package/dist/testing/notices.js +209 -0
  111. package/dist/testing/run.d.ts +174 -0
  112. package/dist/testing/run.js +429 -0
  113. package/dist/testing/users.d.ts +127 -0
  114. package/dist/testing/users.js +78 -0
  115. package/dist/testing/wire.d.ts +62 -0
  116. package/dist/testing/wire.js +148 -0
  117. package/dist/types.d.ts +88 -0
  118. package/dist/types.js +1 -0
  119. package/dist/version.d.ts +2 -0
  120. package/dist/version.js +3 -0
  121. package/package.json +60 -5
@@ -0,0 +1,50 @@
1
+ import { deadlineExceeded, MAX_TIMER_MS } from '@north-light/api';
2
+ export function withQuery(path, query) {
3
+ if (query === undefined)
4
+ return path;
5
+ const search = new URLSearchParams();
6
+ for (const [key, value] of Object.entries(query)) {
7
+ if (value !== undefined)
8
+ search.set(key, String(value));
9
+ }
10
+ const suffix = search.toString();
11
+ return suffix === '' ? path : `${path}?${suffix}`;
12
+ }
13
+ /** A held read's options: `wait` (an integer 1–25 s; anything else throws `RangeError` before sending) extends the
14
+ * request timeout by the hold, so the timeout bounds the answer rather than the wait. Shared by `runs.get` and
15
+ * `grant.get`. */
16
+ export function heldReadOptions(wait, options) {
17
+ if (wait !== undefined && (!Number.isInteger(wait) || wait < 1 || wait > 25))
18
+ throw new RangeError('wait must be 1–25 seconds');
19
+ return wait ? { ...options, timeout: (options.timeout ?? 30_000) + wait * 1_000 } : options;
20
+ }
21
+ /** The call's one deadline, fixed at call start: `budgetMs` from now and/or `deadlineAt`, whichever is earlier, as
22
+ * `deadlineAt` (so a retry after a 401 and every transport attempt share it). Options without either pass through. */
23
+ export function deadline(options) {
24
+ if (options === undefined || (options.budgetMs === undefined && options.deadlineAt === undefined))
25
+ return options;
26
+ const { budgetMs, deadlineAt, ...rest } = options;
27
+ if (budgetMs !== undefined && (!Number.isFinite(budgetMs) || budgetMs <= 0 || budgetMs > MAX_TIMER_MS))
28
+ throw new RangeError(`budgetMs must be a positive finite number of at most ${MAX_TIMER_MS}`);
29
+ if (deadlineAt !== undefined && (!Number.isFinite(deadlineAt) || deadlineAt - Date.now() > MAX_TIMER_MS))
30
+ throw new RangeError(`deadlineAt must be a finite epoch-millisecond instant at most ${MAX_TIMER_MS} ms from now`);
31
+ return { ...rest, deadlineAt: Math.min(deadlineAt ?? Infinity, budgetMs === undefined ? Infinity : Date.now() + budgetMs) };
32
+ }
33
+ /** `promise`, or `deadline_exceeded` once `deadlineAt` passes first. */
34
+ export async function withinDeadline(promise, deadlineAt) {
35
+ if (deadlineAt === undefined)
36
+ return promise;
37
+ const remaining = deadlineAt - Date.now();
38
+ if (remaining <= 0) {
39
+ promise.catch(() => { });
40
+ throw deadlineExceeded(deadlineAt);
41
+ }
42
+ let timer;
43
+ try {
44
+ return await Promise.race([promise, new Promise((_resolve, reject) => { timer = setTimeout(() => reject(deadlineExceeded(deadlineAt)), remaining); })]);
45
+ }
46
+ finally {
47
+ if (timer !== undefined)
48
+ clearTimeout(timer);
49
+ }
50
+ }
@@ -0,0 +1,34 @@
1
+ import { JwksCache } from '@north-light/identity';
2
+ /** The scope a run-notice token carries. */
3
+ export declare const RUN_NOTICE_SCOPE = "crtr:run_notice";
4
+ /** A run notice that failed verification. An app maps it to HTTP 401 and stores nothing. */
5
+ export declare class RunNoticeError extends Error {
6
+ readonly status = 401;
7
+ constructor(message: string);
8
+ }
9
+ export interface RunNotice {
10
+ /** The person the run belongs to, `user:<id>` (the token's `sub`). */
11
+ userId: string;
12
+ /** The runtime that sent it, from the token's `act.sub` (never the body). */
13
+ runtime: string;
14
+ runId: string;
15
+ latestSequence: number;
16
+ }
17
+ export interface VerifyRunNoticeOptions {
18
+ /** The app's registered `run_notice_url`, exactly; the token's `aud` must equal it. */
19
+ runNoticeUrl: string;
20
+ /** The directory issuer. */
21
+ issuer: string;
22
+ /** The app's own principal, `app:<id>`; the token's `grantee` must equal it. */
23
+ grantee: string;
24
+ /** Override the directory key cache (default: one per issuer, keys from `<issuer>/.well-known/jwks.json`). */
25
+ jwks?: JwksCache;
26
+ /** The `fetch` used for the directory's keys when `jwks` is absent (default: the global `fetch`), as
27
+ * `OAuth2Options.fetch`. Keys are cached per `(issuer, fetch)`, so a test double such as
28
+ * `@north-light/sdk/testing`'s `FakeCloud` is verified against its own keys. */
29
+ fetch?: typeof fetch;
30
+ }
31
+ /** Verify a runtime→app run notice: the bearer is a directory-signed outbound token for `runNoticeUrl`
32
+ * (signature, `iss`, `aud`, `exp`), scope exactly `crtr:run_notice`, `grantee` the app's own principal;
33
+ * the body is `{run_id, latest_sequence}`. Throws `RunNoticeError` (answer 401) on any failure. */
34
+ export declare function verifyRunNotice(request: Request, options: VerifyRunNoticeOptions): Promise<RunNotice>;
@@ -0,0 +1,67 @@
1
+ import { JwksCache, verifyOutboundToken } from '@north-light/identity';
2
+ /** The scope a run-notice token carries. */
3
+ export const RUN_NOTICE_SCOPE = 'crtr:run_notice';
4
+ /** A run notice that failed verification. An app maps it to HTTP 401 and stores nothing. */
5
+ export class RunNoticeError extends Error {
6
+ status = 401;
7
+ constructor(message) { super(message); this.name = 'RunNoticeError'; }
8
+ }
9
+ const jwksUrl = (issuer) => `${issuer.replace(/\/+$/, '')}/.well-known/jwks.json`;
10
+ // One cache per issuer for the global fetch, and one per (issuer, fetch) pair for a caller's fetch: a cache built on
11
+ // one fetch must never answer for another (an unknown kid is re-fetched at most every 30 s).
12
+ const caches = new Map();
13
+ const fetchCaches = new Map();
14
+ function cacheFor(issuer, fetcher) {
15
+ if (fetcher === undefined) {
16
+ let cache = caches.get(issuer);
17
+ if (!cache) {
18
+ cache = new JwksCache(issuer, (input, init) => fetch(input, init), jwksUrl(issuer));
19
+ caches.set(issuer, cache);
20
+ }
21
+ return cache;
22
+ }
23
+ let byFetch = fetchCaches.get(issuer);
24
+ if (!byFetch) {
25
+ byFetch = new WeakMap();
26
+ fetchCaches.set(issuer, byFetch);
27
+ }
28
+ let cache = byFetch.get(fetcher);
29
+ if (!cache) {
30
+ cache = new JwksCache(issuer, (input, init) => fetcher(input, init), jwksUrl(issuer));
31
+ byFetch.set(fetcher, cache);
32
+ }
33
+ return cache;
34
+ }
35
+ /** Verify a runtime→app run notice: the bearer is a directory-signed outbound token for `runNoticeUrl`
36
+ * (signature, `iss`, `aud`, `exp`), scope exactly `crtr:run_notice`, `grantee` the app's own principal;
37
+ * the body is `{run_id, latest_sequence}`. Throws `RunNoticeError` (answer 401) on any failure. */
38
+ export async function verifyRunNotice(request, options) {
39
+ const match = /^Bearer ([^\s]+)$/.exec(request.headers.get('authorization') ?? '');
40
+ if (match === null)
41
+ throw new RunNoticeError('The request carries no bearer token.');
42
+ let claims;
43
+ try {
44
+ claims = await verifyOutboundToken({ token: match[1], jwks: options.jwks ?? cacheFor(options.issuer, options.fetch), issuer: options.issuer, audience: options.runNoticeUrl });
45
+ }
46
+ catch {
47
+ throw new RunNoticeError('The bearer token is not a valid directory token for this endpoint.');
48
+ }
49
+ if (claims.scope !== RUN_NOTICE_SCOPE)
50
+ throw new RunNoticeError('The token scope is not crtr:run_notice.');
51
+ if (claims.grantee !== options.grantee)
52
+ throw new RunNoticeError('The token was minted for another app.');
53
+ const runtime = claims.act.sub;
54
+ if (typeof runtime !== 'string' || !runtime.startsWith('runtime:'))
55
+ throw new RunNoticeError('The token names no runtime.');
56
+ let body;
57
+ try {
58
+ body = await request.json();
59
+ }
60
+ catch {
61
+ throw new RunNoticeError('The notice body is not JSON.');
62
+ }
63
+ const { run_id: runId, latest_sequence: latest } = (body ?? {});
64
+ if (typeof runId !== 'string' || runId === '' || typeof latest !== 'number' || !Number.isSafeInteger(latest) || latest < 0)
65
+ throw new RunNoticeError('The notice body is not {run_id, latest_sequence}.');
66
+ return { userId: claims.sub, runtime, runId, latestSequence: latest };
67
+ }
@@ -0,0 +1,79 @@
1
+ import type { AssistantStopReasonDTO, RunOutcomeDTO, RunStatusDTO, TurnErrorDTO } from '@north-light/api';
2
+ import type { RunStream } from './run-stream.js';
3
+ /** What `runs.message` returns: the message a reply answers. */
4
+ export interface SentRunMessage {
5
+ run_id: string;
6
+ message_id: string;
7
+ sequence_number: number;
8
+ }
9
+ /** One event of the reply to a message (`runs.reply`). */
10
+ export type RunReplyEvent =
11
+ /** Streamed text of the turn's current assistant message. */
12
+ {
13
+ type: 'text.delta';
14
+ delta: string;
15
+ sequence_number: number;
16
+ }
17
+ /** One assistant message ended; `text` is its whole text, possibly empty (a refused or failed call writes none). */
18
+ | {
19
+ type: 'message.done';
20
+ text: string;
21
+ stop_reason: AssistantStopReasonDTO;
22
+ sequence_number: number;
23
+ }
24
+ /** The turn ended; always the last event of a reply that reached its turn. `error` is set when the turn's last assistant message stopped with `error`. */
25
+ | {
26
+ type: 'turn.completed';
27
+ run_status: Exclude<RunStatusDTO, 'settled'>;
28
+ stop_reason: string;
29
+ error?: TurnErrorDTO;
30
+ message_ids: string[];
31
+ sequence_number: number;
32
+ }
33
+ /** The run settled before the turn completed; the reply ends here. */
34
+ | {
35
+ type: 'run.settled';
36
+ outcome: RunOutcomeDTO;
37
+ sequence_number: number;
38
+ };
39
+ /** How a reply ended (`RunReply.collect`):
40
+ * - `completed`: the turn finished cleanly; `text` may still be empty when the model wrote nothing.
41
+ * - `error`: the turn's last assistant message failed; `error` says why.
42
+ * - `waiting_on_user`: the turn ended on a question to the person; the run waits for an answer.
43
+ * - `settled`: the run settled before the turn completed; `outcome` says how. */
44
+ export type RunReplyEnding = 'completed' | 'error' | 'waiting_on_user' | 'settled';
45
+ /** The whole reply, from `RunReply.collect`. */
46
+ export interface RunReplyResult {
47
+ /** Every assistant message's trimmed text in the turn, joined with a blank line; empty when the turn wrote none. */
48
+ text: string;
49
+ ended: RunReplyEnding;
50
+ /** Set when `ended` is `error`. */
51
+ error?: TurnErrorDTO;
52
+ /** Set when `ended` is `settled`. */
53
+ outcome?: RunOutcomeDTO;
54
+ }
55
+ /** `RunReply.collect` options. */
56
+ export interface RunReplyCollectOptions {
57
+ /** Called with each streamed piece of text. When a turn speaks again after a tool call, the first piece
58
+ * of the new message starts with a blank line, so the pieces concatenate to `text` (up to trimming). */
59
+ onDelta?: (delta: string) => void;
60
+ }
61
+ /**
62
+ * The reply to one message: the root's turn that delivers it. Consume it once:
63
+ * iterate it, or call `collect()` (text plus how it ended, with an optional
64
+ * `onDelta` while it streams) or `text()`. It ends after `turn.completed` (or `run.settled`) and
65
+ * closes its event stream; aborting the `signal` rejects with `APIUserAbortError`.
66
+ */
67
+ export declare class RunReply implements AsyncIterable<RunReplyEvent> {
68
+ private readonly open;
69
+ private readonly sent;
70
+ private consumed;
71
+ constructor(open: (after: number) => RunStream, sent: SentRunMessage);
72
+ [Symbol.asyncIterator](): AsyncIterator<RunReplyEvent>;
73
+ /** Consume the reply: stream its text to `onDelta` and resolve to the whole text and how the turn ended.
74
+ * A stream that closes before the turn ends throws, as iterating does. */
75
+ collect(options?: RunReplyCollectOptions): Promise<RunReplyResult>;
76
+ /** The reply's text: every assistant message's trimmed text in the turn, joined with a blank line. Empty when the turn wrote none.
77
+ * `onDelta` receives the text as it streams, as in `collect`. */
78
+ text(options?: RunReplyCollectOptions): Promise<string>;
79
+ }
@@ -0,0 +1,93 @@
1
+ import { APIError } from '../errors.js';
2
+ /**
3
+ * The reply to one message: the root's turn that delivers it. Consume it once:
4
+ * iterate it, or call `collect()` (text plus how it ended, with an optional
5
+ * `onDelta` while it streams) or `text()`. It ends after `turn.completed` (or `run.settled`) and
6
+ * closes its event stream; aborting the `signal` rejects with `APIUserAbortError`.
7
+ */
8
+ export class RunReply {
9
+ open;
10
+ sent;
11
+ consumed = false;
12
+ constructor(open, sent) {
13
+ this.open = open;
14
+ this.sent = sent;
15
+ }
16
+ async *[Symbol.asyncIterator]() {
17
+ if (this.consumed)
18
+ throw new Error('a RunReply can be consumed only once');
19
+ this.consumed = true;
20
+ let stream = this.open(this.sent.sequence_number);
21
+ let inTurn = false;
22
+ try {
23
+ for (;;) {
24
+ for await (const event of stream) {
25
+ if (event.node_id !== this.sent.run_id)
26
+ continue;
27
+ if (event.type === 'node.settled') {
28
+ yield { type: 'run.settled', outcome: event.outcome, sequence_number: event.sequence_number };
29
+ return;
30
+ }
31
+ if (!inTurn) {
32
+ inTurn = event.type === 'node.turn.started' && event.input.message_ids.includes(this.sent.message_id);
33
+ continue;
34
+ }
35
+ if (event.type === 'node.output_text.delta')
36
+ yield { type: 'text.delta', delta: event.delta, sequence_number: event.sequence_number };
37
+ else if (event.type === 'node.output_text.done')
38
+ yield { type: 'message.done', text: event.text, stop_reason: event.stop_reason, sequence_number: event.sequence_number };
39
+ else if (event.type === 'node.turn.completed') {
40
+ yield {
41
+ type: 'turn.completed', run_status: event.run_status, stop_reason: event.stop_reason,
42
+ ...(event.error ? { error: event.error } : {}), message_ids: event.message_ids, sequence_number: event.sequence_number,
43
+ };
44
+ return;
45
+ }
46
+ }
47
+ // The runtime closed a quiet stream (`event: idle`) before the turn ended. A reply still waits for its turn, so it reopens from the cursor.
48
+ if (!stream.idle)
49
+ return;
50
+ const next = stream.position;
51
+ stream.abort();
52
+ await new Promise((resolve) => setTimeout(resolve, 500));
53
+ stream = this.open(next);
54
+ }
55
+ }
56
+ finally {
57
+ stream.abort();
58
+ }
59
+ }
60
+ /** Consume the reply: stream its text to `onDelta` and resolve to the whole text and how the turn ended.
61
+ * A stream that closes before the turn ends throws, as iterating does. */
62
+ async collect(options = {}) {
63
+ const texts = [];
64
+ let partial = false;
65
+ for await (const event of this) {
66
+ if (event.type === 'text.delta') {
67
+ if (!event.delta)
68
+ continue;
69
+ options.onDelta?.(!partial && texts.length ? `\n\n${event.delta}` : event.delta);
70
+ partial = true;
71
+ }
72
+ else if (event.type === 'message.done') {
73
+ if (event.text.trim())
74
+ texts.push(event.text.trim());
75
+ partial = false;
76
+ }
77
+ else {
78
+ const text = texts.join('\n\n');
79
+ if (event.type === 'run.settled')
80
+ return { text, ended: 'settled', outcome: event.outcome };
81
+ if (event.error)
82
+ return { text, ended: 'error', error: event.error };
83
+ return { text, ended: event.run_status === 'waiting_on_user' ? 'waiting_on_user' : 'completed' };
84
+ }
85
+ }
86
+ throw new APIError(0, 'stream_error', 'the run event stream ended before the reply did', undefined, undefined, { retryable: false, run_id: this.sent.run_id });
87
+ }
88
+ /** The reply's text: every assistant message's trimmed text in the turn, joined with a blank line. Empty when the turn wrote none.
89
+ * `onDelta` receives the text as it streams, as in `collect`. */
90
+ async text(options = {}) {
91
+ return (await this.collect(options)).text;
92
+ }
93
+ }
@@ -0,0 +1,65 @@
1
+ import type { RunEvent, RunEventBaseDTO, RunEventOf, RunEventType } from '@north-light/api';
2
+ /** Every run event, discriminated on `type`: the daemon declares the union once and the SDK re-exports it. */
3
+ export type { RunEvent, RunEventOf, RunEventType };
4
+ export type RunEventListener<T extends RunEventType = RunEventType> = (event: RunEventOf<T>) => void;
5
+ /** An event under a type this SDK version does not declare (a newer daemon's), or read by field name. */
6
+ export type UnknownRunEvent = RunEventBaseDTO & {
7
+ type: string;
8
+ [field: string]: unknown;
9
+ };
10
+ /** How a `RunStream` ended without an error: the run `settled`, or the runtime closed an `idle` stream
11
+ * (no running turn and no new event for its quiet window) and `cursor` is where to reopen it. */
12
+ export interface RunStreamEnd {
13
+ reason: 'settled' | 'idle';
14
+ cursor: number;
15
+ }
16
+ /** A resumable run event stream. Closing it does not stop the run.
17
+ *
18
+ * Iteration ends normally in exactly two cases, told apart by `idle`: the run settled (`idle` is false) or the
19
+ * runtime sent `event: idle` and closed (`idle` is true: the run is quiet, nothing is lost, and `cursor` is where to
20
+ * reopen with `runs.events(runId, {after: cursor})` when there is something to read). After the idle frame the
21
+ * stream does not reconnect on its own. A transport or protocol failure throws instead, as before. */
22
+ export declare class RunStream implements AsyncIterable<RunEvent> {
23
+ private readonly open;
24
+ private readonly onReconnect?;
25
+ private readonly controller;
26
+ private readonly listeners;
27
+ private cursor;
28
+ private readonly outcome;
29
+ private resolveOutcome;
30
+ private rejectOutcome;
31
+ private finished;
32
+ private readonly events;
33
+ private wake?;
34
+ private failure;
35
+ private idled;
36
+ private readonly ended;
37
+ private resolveEnded;
38
+ private rejectEnded;
39
+ constructor(open: (after: number, signal: AbortSignal) => Promise<Response>, after?: number, signal?: AbortSignal, onReconnect?: ((state: "reconnecting" | "reconnected", error?: unknown) => void) | undefined);
40
+ on<T extends RunEventType>(type: T, listener: RunEventListener<T>): this;
41
+ on(type: string, listener: (event: RunEvent | UnknownRunEvent) => void): this;
42
+ off<T extends RunEventType>(type: T, listener: RunEventListener<T>): this;
43
+ off(type: string, listener: (event: RunEvent | UnknownRunEvent) => void): this;
44
+ abort(): void;
45
+ /** True once the runtime sent `event: idle` and closed the stream: the run has had no running turn and no new
46
+ * event for the runtime's quiet window. The stream has ended and will not reconnect. */
47
+ get idle(): boolean;
48
+ /** The `sequence_number` of the last event delivered (the `after` this stream was given until one arrives).
49
+ * After `idle`, reopen with `runs.events(runId, {after: stream.cursor})`. */
50
+ get position(): number;
51
+ /** Resolves when the stream ends without an error: `{reason: 'settled', cursor}` after `node.settled`, or
52
+ * `{reason: 'idle', cursor}` after the runtime's idle close. Rejects with the transport or protocol failure, or
53
+ * `APIUserAbortError` after `abort()`. */
54
+ whenEnded(): Promise<RunStreamEnd>;
55
+ /** Resolves to the run's `node.settled` event. */
56
+ finalOutcome(): Promise<RunEventOf<'node.settled'>>;
57
+ /** The run's `waiting_on_user` events: each carries a question (`question_id, initiating_app, title, subtitle?, steps, slots`). */
58
+ waitingOnUser(): AsyncIterable<RunEventOf<'waiting_on_user'>>;
59
+ [Symbol.asyncIterator](): AsyncIterator<RunEvent>;
60
+ private finish;
61
+ private deliver;
62
+ private consume;
63
+ private record;
64
+ private deliverPayload;
65
+ }
@@ -0,0 +1,243 @@
1
+ import { APIError, APIUserAbortError, mapError } from '../errors.js';
2
+ /** A resumable run event stream. Closing it does not stop the run.
3
+ *
4
+ * Iteration ends normally in exactly two cases, told apart by `idle`: the run settled (`idle` is false) or the
5
+ * runtime sent `event: idle` and closed (`idle` is true: the run is quiet, nothing is lost, and `cursor` is where to
6
+ * reopen with `runs.events(runId, {after: cursor})` when there is something to read). After the idle frame the
7
+ * stream does not reconnect on its own. A transport or protocol failure throws instead, as before. */
8
+ export class RunStream {
9
+ open;
10
+ onReconnect;
11
+ controller = new AbortController();
12
+ listeners = new Map();
13
+ cursor;
14
+ outcome;
15
+ resolveOutcome;
16
+ rejectOutcome;
17
+ finished = false;
18
+ events = [];
19
+ wake;
20
+ failure;
21
+ idled = false;
22
+ ended;
23
+ resolveEnded;
24
+ rejectEnded;
25
+ constructor(open, after = 0, signal, onReconnect) {
26
+ this.open = open;
27
+ this.onReconnect = onReconnect;
28
+ if (!Number.isSafeInteger(after) || after < 0)
29
+ throw new RangeError('after must be a non-negative safe integer');
30
+ this.cursor = after;
31
+ this.outcome = new Promise((resolve, reject) => { this.resolveOutcome = resolve; this.rejectOutcome = reject; });
32
+ void this.outcome.catch(() => undefined);
33
+ this.ended = new Promise((resolve, reject) => { this.resolveEnded = resolve; this.rejectEnded = reject; });
34
+ void this.ended.catch(() => undefined);
35
+ if (signal?.aborted)
36
+ this.controller.abort(signal.reason);
37
+ else
38
+ signal?.addEventListener('abort', () => this.controller.abort(signal.reason), { once: true });
39
+ void this.consume();
40
+ }
41
+ on(type, listener) {
42
+ const set = this.listeners.get(type) ?? new Set();
43
+ set.add(listener);
44
+ this.listeners.set(type, set);
45
+ return this;
46
+ }
47
+ off(type, listener) { this.listeners.get(type)?.delete(listener); return this; }
48
+ abort() { this.controller.abort(); }
49
+ /** True once the runtime sent `event: idle` and closed the stream: the run has had no running turn and no new
50
+ * event for the runtime's quiet window. The stream has ended and will not reconnect. */
51
+ get idle() { return this.idled; }
52
+ /** The `sequence_number` of the last event delivered (the `after` this stream was given until one arrives).
53
+ * After `idle`, reopen with `runs.events(runId, {after: stream.cursor})`. */
54
+ get position() { return this.cursor; }
55
+ /** Resolves when the stream ends without an error: `{reason: 'settled', cursor}` after `node.settled`, or
56
+ * `{reason: 'idle', cursor}` after the runtime's idle close. Rejects with the transport or protocol failure, or
57
+ * `APIUserAbortError` after `abort()`. */
58
+ whenEnded() { return this.ended; }
59
+ /** Resolves to the run's `node.settled` event. */
60
+ finalOutcome() { return this.outcome; }
61
+ /** The run's `waiting_on_user` events: each carries a question (`question_id, initiating_app, title, subtitle?, steps, slots`). */
62
+ async *waitingOnUser() {
63
+ for await (const event of this)
64
+ if (event.type === 'waiting_on_user')
65
+ yield event;
66
+ }
67
+ async *[Symbol.asyncIterator]() {
68
+ for (;;) {
69
+ if (this.events.length) {
70
+ yield this.events.shift();
71
+ continue;
72
+ }
73
+ if (this.finished) {
74
+ if (this.failure)
75
+ throw this.failure;
76
+ return;
77
+ }
78
+ await new Promise((resolve) => { this.wake = resolve; });
79
+ }
80
+ }
81
+ finish(error) {
82
+ if (this.finished)
83
+ return;
84
+ this.finished = true;
85
+ this.failure = error;
86
+ if (error) {
87
+ this.rejectOutcome(error);
88
+ this.rejectEnded(error);
89
+ }
90
+ else
91
+ this.resolveEnded({ reason: this.idled ? 'idle' : 'settled', cursor: this.cursor });
92
+ this.wake?.();
93
+ this.wake = undefined;
94
+ }
95
+ deliver(event) {
96
+ if (!Number.isSafeInteger(event.sequence_number) || event.sequence_number <= this.cursor)
97
+ return;
98
+ this.cursor = event.sequence_number;
99
+ this.events.push(event);
100
+ for (const listener of this.listeners.get(event.type) ?? [])
101
+ listener(event);
102
+ this.wake?.();
103
+ this.wake = undefined;
104
+ if (event.type === 'node.settled') {
105
+ this.resolveOutcome(event);
106
+ this.finish();
107
+ }
108
+ }
109
+ async consume() {
110
+ let attempt = 0;
111
+ let failedReconnect = false;
112
+ while (!this.controller.signal.aborted && !this.finished) {
113
+ const connection = new AbortController();
114
+ const stop = () => connection.abort();
115
+ this.controller.signal.addEventListener('abort', stop, { once: true });
116
+ let idle;
117
+ let opened = false;
118
+ const resetIdle = () => { if (idle)
119
+ clearTimeout(idle); idle = setTimeout(() => connection.abort(), 45_000); };
120
+ try {
121
+ const response = await this.open(this.cursor, connection.signal);
122
+ opened = true;
123
+ if (!response.ok)
124
+ throw await streamResponseError(response);
125
+ if (!response.body)
126
+ throw new Error('Run event stream has no body');
127
+ if (failedReconnect) {
128
+ this.onReconnect?.('reconnected');
129
+ failedReconnect = false;
130
+ }
131
+ const reader = response.body.getReader();
132
+ const decoder = new TextDecoder();
133
+ const ndjson = response.headers.get('content-type')?.includes('application/x-ndjson') ?? false;
134
+ let buffer = '';
135
+ if (!ndjson)
136
+ resetIdle();
137
+ for (;;) {
138
+ const { done, value } = await reader.read();
139
+ if (done)
140
+ break;
141
+ if (!ndjson)
142
+ resetIdle();
143
+ buffer += decoder.decode(value, { stream: true });
144
+ const records = ndjson ? buffer.split(/\r?\n/) : buffer.split(/\r\n\r\n|\n\n|\r\r/);
145
+ buffer = records.pop() ?? '';
146
+ for (const record of records) {
147
+ if (ndjson)
148
+ this.deliverPayload(JSON.parse(record));
149
+ else
150
+ this.record(record);
151
+ if (this.idled)
152
+ this.finish();
153
+ if (this.finished) {
154
+ await reader.cancel();
155
+ return;
156
+ }
157
+ }
158
+ }
159
+ if (buffer) {
160
+ if (ndjson)
161
+ this.deliverPayload(JSON.parse(buffer));
162
+ else
163
+ this.record(buffer);
164
+ if (this.idled)
165
+ this.finish();
166
+ if (this.finished)
167
+ return;
168
+ }
169
+ attempt = 0;
170
+ }
171
+ catch (error) {
172
+ if (this.controller.signal.aborted)
173
+ break;
174
+ if ((!opened && !(error instanceof APIError)) ||
175
+ (error instanceof APIError && (error.code === 'stream_dropped' || error.code === 'stream_error' || !error.retryable))) {
176
+ this.finish(error);
177
+ return;
178
+ }
179
+ if (attempt > 0) {
180
+ failedReconnect = true;
181
+ this.onReconnect?.('reconnecting', error);
182
+ }
183
+ }
184
+ finally {
185
+ if (idle)
186
+ clearTimeout(idle);
187
+ this.controller.signal.removeEventListener('abort', stop);
188
+ }
189
+ if (this.finished || this.controller.signal.aborted)
190
+ break;
191
+ const delay = Math.min(30_000, 500 * 2 ** Math.min(attempt++, 6));
192
+ try {
193
+ await new Promise((resolve, reject) => {
194
+ const timer = setTimeout(resolve, delay);
195
+ this.controller.signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('aborted')); }, { once: true });
196
+ });
197
+ }
198
+ catch {
199
+ break;
200
+ }
201
+ }
202
+ if (this.controller.signal.aborted)
203
+ this.finish(new APIUserAbortError(0, 'request_aborted', 'run stream aborted by caller'));
204
+ }
205
+ record(record) {
206
+ let type;
207
+ const data = [];
208
+ for (const line of record.split(/\r\n|\n|\r/)) {
209
+ if (line.startsWith(':'))
210
+ continue;
211
+ const separator = line.indexOf(':');
212
+ const field = separator === -1 ? line : line.slice(0, separator);
213
+ const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
214
+ if (field === 'event')
215
+ type = value;
216
+ if (field === 'data')
217
+ data.push(value);
218
+ }
219
+ // The runtime's idle close: a quiet run, not an event. It carries no sequence number and is never delivered.
220
+ if (type === 'idle') {
221
+ if (!this.finished)
222
+ this.idled = true;
223
+ return;
224
+ }
225
+ if (!type || !data.length)
226
+ return;
227
+ const payload = JSON.parse(data.join('\n'));
228
+ this.deliverPayload({ ...payload, type });
229
+ }
230
+ deliverPayload(payload) {
231
+ if (payload.type === 'error') {
232
+ const envelope = (payload.error ?? payload);
233
+ throw new APIError(0, envelope.code ?? 'stream_error', envelope.message ?? 'Run stream error', envelope.details, undefined, envelope);
234
+ }
235
+ // A newer daemon may send an event type this SDK does not declare; it is delivered as-is.
236
+ this.deliver(payload);
237
+ }
238
+ }
239
+ async function streamResponseError(response) {
240
+ const payload = await response.json();
241
+ const error = payload.error;
242
+ return mapError(new APIError(response.status, error?.code ?? 'invalid_response', error?.message ?? `HTTP ${response.status}`, error?.details, response.headers, error));
243
+ }