@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.
- package/LICENSE +18 -0
- package/README.md +187 -2
- package/dist/client.d.ts +176 -0
- package/dist/client.js +582 -0
- package/dist/crtr.d.ts +24 -0
- package/dist/crtr.js +50 -0
- package/dist/env-command.d.ts +15 -0
- package/dist/env-command.js +138 -0
- package/dist/error-codes.d.ts +7 -0
- package/dist/error-codes.js +43 -0
- package/dist/errors.d.ts +72 -0
- package/dist/errors.js +101 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +17 -0
- package/dist/keygen-cli.d.ts +2 -0
- package/dist/keygen-cli.js +11 -0
- package/dist/keygen-command.d.ts +6 -0
- package/dist/keygen-command.js +131 -0
- package/dist/oauth/index.d.ts +235 -0
- package/dist/oauth/index.js +621 -0
- package/dist/oauth/keygen.d.ts +18 -0
- package/dist/oauth/keygen.js +46 -0
- package/dist/oauth/refusal.d.ts +32 -0
- package/dist/oauth/refusal.js +17 -0
- package/dist/resources/activity.d.ts +22 -0
- package/dist/resources/activity.js +43 -0
- package/dist/resources/attachments.d.ts +15 -0
- package/dist/resources/attachments.js +13 -0
- package/dist/resources/bash.d.ts +9 -0
- package/dist/resources/bash.js +15 -0
- package/dist/resources/canvas/history.d.ts +11 -0
- package/dist/resources/canvas/history.js +20 -0
- package/dist/resources/canvas.d.ts +19 -0
- package/dist/resources/canvas.js +42 -0
- package/dist/resources/crons.d.ts +15 -0
- package/dist/resources/crons.js +33 -0
- package/dist/resources/custom-objects.d.ts +20 -0
- package/dist/resources/custom-objects.js +84 -0
- package/dist/resources/files.d.ts +34 -0
- package/dist/resources/files.js +34 -0
- package/dist/resources/follower.d.ts +71 -0
- package/dist/resources/follower.js +202 -0
- package/dist/resources/forward.d.ts +7 -0
- package/dist/resources/forward.js +67 -0
- package/dist/resources/grant-watcher.d.ts +57 -0
- package/dist/resources/grant-watcher.js +100 -0
- package/dist/resources/human/inbox.d.ts +14 -0
- package/dist/resources/human/inbox.js +30 -0
- package/dist/resources/human/requests.d.ts +13 -0
- package/dist/resources/human/requests.js +26 -0
- package/dist/resources/human.d.ts +8 -0
- package/dist/resources/human.js +10 -0
- package/dist/resources/identifiers.d.ts +8 -0
- package/dist/resources/identifiers.js +33 -0
- package/dist/resources/memory.d.ts +69 -0
- package/dist/resources/memory.js +30 -0
- package/dist/resources/models/config.d.ts +8 -0
- package/dist/resources/models/config.js +9 -0
- package/dist/resources/models/credentials.d.ts +10 -0
- package/dist/resources/models/credentials.js +17 -0
- package/dist/resources/models.d.ts +8 -0
- package/dist/resources/models.js +10 -0
- package/dist/resources/node-stream.d.ts +34 -0
- package/dist/resources/node-stream.js +176 -0
- package/dist/resources/nodes/jobs.d.ts +9 -0
- package/dist/resources/nodes/jobs.js +14 -0
- package/dist/resources/nodes/result.d.ts +8 -0
- package/dist/resources/nodes/result.js +11 -0
- package/dist/resources/nodes/worktree.d.ts +9 -0
- package/dist/resources/nodes/worktree.js +14 -0
- package/dist/resources/nodes.d.ts +23 -0
- package/dist/resources/nodes.js +47 -0
- package/dist/resources/providers.d.ts +51 -0
- package/dist/resources/providers.js +15 -0
- package/dist/resources/questions.d.ts +49 -0
- package/dist/resources/questions.js +21 -0
- package/dist/resources/request.d.ts +12 -0
- package/dist/resources/request.js +50 -0
- package/dist/resources/run-notice.d.ts +34 -0
- package/dist/resources/run-notice.js +67 -0
- package/dist/resources/run-reply.d.ts +79 -0
- package/dist/resources/run-reply.js +93 -0
- package/dist/resources/run-stream.d.ts +65 -0
- package/dist/resources/run-stream.js +243 -0
- package/dist/resources/run-watcher.d.ts +98 -0
- package/dist/resources/run-watcher.js +162 -0
- package/dist/resources/runs.d.ts +199 -0
- package/dist/resources/runs.js +228 -0
- package/dist/resources/shares.d.ts +40 -0
- package/dist/resources/shares.js +19 -0
- package/dist/resources/structured-requests.d.ts +14 -0
- package/dist/resources/structured-requests.js +19 -0
- package/dist/resources/uploads.d.ts +27 -0
- package/dist/resources/uploads.js +11 -0
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +10 -0
- package/dist/stores/postgres.d.ts +32 -0
- package/dist/stores/postgres.js +92 -0
- package/dist/testing/clock.d.ts +7 -0
- package/dist/testing/clock.js +12 -0
- package/dist/testing/cloud.d.ts +228 -0
- package/dist/testing/cloud.js +914 -0
- package/dist/testing/conformance/index.d.ts +2 -0
- package/dist/testing/conformance/index.js +3 -0
- package/dist/testing/conformance/keyed-write.d.ts +43 -0
- package/dist/testing/conformance/keyed-write.js +124 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/notices.d.ts +76 -0
- package/dist/testing/notices.js +209 -0
- package/dist/testing/run.d.ts +174 -0
- package/dist/testing/run.js +429 -0
- package/dist/testing/users.d.ts +127 -0
- package/dist/testing/users.js +78 -0
- package/dist/testing/wire.d.ts +62 -0
- package/dist/testing/wire.js +148 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/package.json +60 -5
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// `@north-light/sdk/testing/conformance`: executable scenarios that hold a test double of the crouter runtime to the
|
|
2
|
+
// daemon's real behaviour. Framework-agnostic: they assert with `node:assert/strict` and run under any test runner.
|
|
3
|
+
export { IDEMPOTENCY_WINDOW_MS, keyedWriteScenarios } from './keyed-write.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** How long a kept key replays, in ms. A record of this age or older reads as absent. Equal to the daemon's constant. */
|
|
2
|
+
export declare const IDEMPOTENCY_WINDOW_MS = 86400000;
|
|
3
|
+
/** A message the run accepted, in acceptance order. */
|
|
4
|
+
export interface AcceptedMessage {
|
|
5
|
+
id: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
/** What a target's `classify` makes of a thrown error. */
|
|
9
|
+
export type KeyedWriteFailure = 'idempotency_conflict' | 'run_settled' | 'other';
|
|
10
|
+
/**
|
|
11
|
+
* One caller on the target: a person using an app, with keys of their own. The daemon scopes a key to the app (the
|
|
12
|
+
* grantee), so two callers that must not share keys are different apps there; on a target scoped by person they are
|
|
13
|
+
* different people. Every write carries an `Idempotency-Key`.
|
|
14
|
+
*/
|
|
15
|
+
export interface KeyedWriteCaller {
|
|
16
|
+
/** Start a run; returns the run id. */
|
|
17
|
+
start(key: string, prompt: string): Promise<string>;
|
|
18
|
+
/** A message that starts a turn; returns the message id. */
|
|
19
|
+
message(runId: string, key: string, text: string): Promise<string>;
|
|
20
|
+
/** A message with `start_turn: false`; returns the message id. */
|
|
21
|
+
note(runId: string, key: string, text: string): Promise<string>;
|
|
22
|
+
/** Cancel the run. */
|
|
23
|
+
cancel(runId: string): Promise<void>;
|
|
24
|
+
/** The messages the run accepted, in order. A start without `message` contributes none. */
|
|
25
|
+
accepted(runId: string): Promise<AcceptedMessage[]>;
|
|
26
|
+
}
|
|
27
|
+
/** What a runtime double (or the daemon) supplies; the scenarios call nothing else. */
|
|
28
|
+
export interface KeyedWriteTarget {
|
|
29
|
+
/** A new, independent caller that shares no keys with any other: one the target has never seen, with an active grant. */
|
|
30
|
+
user(): Promise<KeyedWriteCaller> | KeyedWriteCaller;
|
|
31
|
+
/** Make every stored key `ms` older: advance the target's clock, or backdate the records. A target on the real clock
|
|
32
|
+
* keeps running while a scenario does, so K4 stops a minute short of the window instead of one ms short; a target
|
|
33
|
+
* on a manual clock proves the exact boundary in its own test. */
|
|
34
|
+
age(ms: number): Promise<void> | void;
|
|
35
|
+
/** Map the target's thrown error. */
|
|
36
|
+
classify(error: unknown): KeyedWriteFailure;
|
|
37
|
+
}
|
|
38
|
+
export interface KeyedWriteScenario {
|
|
39
|
+
/** `K1 …`: stable, unique, safe to use as a test title. */
|
|
40
|
+
name: string;
|
|
41
|
+
run(target: KeyedWriteTarget): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export declare const keyedWriteScenarios: KeyedWriteScenario[];
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Keyed-write scenarios K1–K8: the idempotent accept of `run.start` and `run.message`, as the daemon defines it
|
|
2
|
+
// (core/runs/operations.ts). Scope is (grantee, key); the two operations share one key space; the request hash
|
|
3
|
+
// covers the operation and its target; a record at least `IDEMPOTENCY_WINDOW_MS` old reads as absent.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
/** How long a kept key replays, in ms. A record of this age or older reads as absent. Equal to the daemon's constant. */
|
|
6
|
+
export const IDEMPOTENCY_WINDOW_MS = 86_400_000;
|
|
7
|
+
async function failure(target, attempt) {
|
|
8
|
+
const settled = await Promise.allSettled([attempt]);
|
|
9
|
+
const [result] = settled;
|
|
10
|
+
assert.equal(result.status, 'rejected', 'the write was expected to fail but was accepted');
|
|
11
|
+
return target.classify(result.reason);
|
|
12
|
+
}
|
|
13
|
+
/** How far short of the window K4's first leg stops: more than a real-clock target can run during the scenario. */
|
|
14
|
+
const SHORT_OF_WINDOW_MS = 60_000;
|
|
15
|
+
const ids = (messages) => messages.map((message) => message.id);
|
|
16
|
+
/** A fresh run for a scenario to write to: its own caller and a start key unique to the scenario. */
|
|
17
|
+
async function fresh(target, label) {
|
|
18
|
+
const caller = await target.user();
|
|
19
|
+
return { caller, runId: await caller.start(`${label}-start`, `${label} prompt`) };
|
|
20
|
+
}
|
|
21
|
+
export const keyedWriteScenarios = [
|
|
22
|
+
{
|
|
23
|
+
name: 'K1 an equal key replays the first answer',
|
|
24
|
+
async run(target) {
|
|
25
|
+
const caller = await target.user();
|
|
26
|
+
const first = await caller.start('k1-start', 'hello');
|
|
27
|
+
assert.equal(await caller.start('k1-start', 'hello'), first, 'a repeated start returns the same run id');
|
|
28
|
+
const m1 = await caller.message(first, 'k1-msg', 'one');
|
|
29
|
+
assert.equal(await caller.message(first, 'k1-msg', 'one'), m1, 'a repeated message returns the same message id');
|
|
30
|
+
assert.deepEqual(await caller.accepted(first), [{ id: m1, text: 'one' }]);
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'K2 a key reused with another request is idempotency_conflict',
|
|
35
|
+
async run(target) {
|
|
36
|
+
const { caller, runId } = await fresh(target, 'k2');
|
|
37
|
+
const m1 = await caller.message(runId, 'k2-msg', 'one');
|
|
38
|
+
assert.equal(await failure(target, caller.message(runId, 'k2-msg', 'other')), 'idempotency_conflict');
|
|
39
|
+
assert.equal(await failure(target, caller.start('k2-start', 'another prompt')), 'idempotency_conflict');
|
|
40
|
+
assert.deepEqual(await caller.accepted(runId), [{ id: m1, text: 'one' }], 'the refused write accepted nothing');
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'K3 concurrent equal-key writes are accepted once',
|
|
45
|
+
async run(target) {
|
|
46
|
+
const { caller, runId } = await fresh(target, 'k3');
|
|
47
|
+
const messages = await Promise.all([1, 2, 3].map(() => caller.message(runId, 'k3-msg', 'same')));
|
|
48
|
+
assert.equal(new Set(messages).size, 1, 'three concurrent messages return one message id');
|
|
49
|
+
assert.deepEqual(await caller.accepted(runId), [{ id: messages[0], text: 'same' }]);
|
|
50
|
+
const starts = await Promise.all([1, 2, 3].map(() => caller.start('k3-concurrent-start', 'same prompt')));
|
|
51
|
+
assert.equal(new Set(starts).size, 1, 'three concurrent starts return one run id');
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'K4 a key expires after the window',
|
|
56
|
+
async run(target) {
|
|
57
|
+
const { caller, runId } = await fresh(target, 'k4');
|
|
58
|
+
const m1 = await caller.message(runId, 'k4-msg', 'one');
|
|
59
|
+
await target.age(IDEMPOTENCY_WINDOW_MS - SHORT_OF_WINDOW_MS);
|
|
60
|
+
assert.equal(await caller.message(runId, 'k4-msg', 'one'), m1, 'a key short of the window still replays');
|
|
61
|
+
assert.equal((await caller.accepted(runId)).length, 1);
|
|
62
|
+
await target.age(SHORT_OF_WINDOW_MS);
|
|
63
|
+
const m2 = await caller.message(runId, 'k4-msg', 'one');
|
|
64
|
+
assert.notEqual(m2, m1, 'a key at the window reads as absent: the write is accepted anew');
|
|
65
|
+
assert.deepEqual(ids(await caller.accepted(runId)), [m1, m2]);
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'K5 a replay after cancel returns the original; a new write is run_settled',
|
|
70
|
+
async run(target) {
|
|
71
|
+
for (const kind of ['message', 'note']) {
|
|
72
|
+
const { caller, runId } = await fresh(target, `k5-${kind}`);
|
|
73
|
+
const write = (key) => (kind === 'message' ? caller.message(runId, key, 'text') : caller.note(runId, key, 'text'));
|
|
74
|
+
const first = await write('k5-key');
|
|
75
|
+
await caller.cancel(runId);
|
|
76
|
+
assert.equal(await write('k5-key'), first, `a ${kind} key replays its original after cancel`);
|
|
77
|
+
assert.equal(await failure(target, write('k5-new')), 'run_settled', `a new ${kind} key on a canceled run`);
|
|
78
|
+
assert.deepEqual(await caller.accepted(runId), [{ id: first, text: 'text' }], 'nothing new was accepted');
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'K6 a key is bound to its operation',
|
|
84
|
+
async run(target) {
|
|
85
|
+
const { caller, runId } = await fresh(target, 'k6');
|
|
86
|
+
assert.equal(await failure(target, caller.message(runId, 'k6-start', 'x')), 'idempotency_conflict', 'a start key presented to message');
|
|
87
|
+
const m1 = await caller.message(runId, 'k6-msg', 'same text');
|
|
88
|
+
assert.equal(await failure(target, caller.note(runId, 'k6-msg', 'same text')), 'idempotency_conflict', 'a message key presented to note');
|
|
89
|
+
const n1 = await caller.note(runId, 'k6-note', 'same text');
|
|
90
|
+
assert.equal(await failure(target, caller.message(runId, 'k6-note', 'same text')), 'idempotency_conflict', 'a note key presented to message');
|
|
91
|
+
assert.deepEqual(ids(await caller.accepted(runId)), [m1, n1], 'only the first writes were accepted');
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'K7 keys are scoped to the grantee',
|
|
96
|
+
async run(target) {
|
|
97
|
+
const a = await target.user();
|
|
98
|
+
const b = await target.user();
|
|
99
|
+
const runA = await a.start('k7-start', 'a prompt');
|
|
100
|
+
const m1 = await a.message(runA, 'k7-msg', 'text');
|
|
101
|
+
const runB = await b.start('k7-start', 'b prompt');
|
|
102
|
+
assert.notEqual(runB, runA, "B's start under A's key is a new run, not a conflict");
|
|
103
|
+
const m2 = await b.message(runB, 'k7-msg', 'text');
|
|
104
|
+
assert.deepEqual(await b.accepted(runB), [{ id: m2, text: 'text' }]);
|
|
105
|
+
assert.deepEqual(await a.accepted(runA), [{ id: m1, text: 'text' }], "B's writes did not touch A's run");
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'K8 concurrent notes, and a note against a message, are accepted once',
|
|
110
|
+
async run(target) {
|
|
111
|
+
const { caller, runId } = await fresh(target, 'k8');
|
|
112
|
+
const notes = await Promise.all([1, 2, 3].map(() => caller.note(runId, 'k8-note', 'noted')));
|
|
113
|
+
assert.equal(new Set(notes).size, 1, 'three concurrent notes return one message id');
|
|
114
|
+
assert.deepEqual(await caller.accepted(runId), [{ id: notes[0], text: 'noted' }]);
|
|
115
|
+
const pair = await Promise.allSettled([caller.message(runId, 'k8-pair', 'same'), caller.note(runId, 'k8-pair', 'same')]);
|
|
116
|
+
const accepted = pair.filter((result) => result.status === 'fulfilled');
|
|
117
|
+
const refused = pair.filter((result) => result.status === 'rejected');
|
|
118
|
+
assert.equal(accepted.length, 1, 'exactly one of the pair is accepted');
|
|
119
|
+
assert.equal(refused.length, 1);
|
|
120
|
+
assert.equal(target.classify(refused[0].reason), 'idempotency_conflict', 'the other is idempotency_conflict');
|
|
121
|
+
assert.equal((await caller.accepted(runId)).length, 2, 'the note and one of the pair');
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { FakeCloud } from './cloud.js';
|
|
2
|
+
export { manualClock } from './clock.js';
|
|
3
|
+
export type { ManualClock } from './clock.js';
|
|
4
|
+
export type { FakeAnswer, FakeCall, FakeCloudOptions, FakeDirectoryCall, FakeDirectoryOp, FakeInterceptOp, FakeOp, FakeProvider, FakeRuns, FakeToolHandler, Intercept, } from './cloud.js';
|
|
5
|
+
export type { FakeRun, FakeRunEnding, FakeRunMessage, FakeRunParams, FakeQuestion } from './run.js';
|
|
6
|
+
export type { FakeNotice, RunNoticeOptions } from './notices.js';
|
|
7
|
+
export type { FakeMemoryDoc, FakeRefusalCode, FakeScope, FakeUser, GrantPatch, Responder, ResponderInput } from './users.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// `@north-light/sdk/testing`: a fake crouter cloud (directory + runtime) your real `OAuth2Client` and `Crouter` talk to
|
|
2
|
+
// through `fetch`. Node-only; no test framework. See docs/sdk/testing.md.
|
|
3
|
+
export { FakeCloud } from './cloud.js';
|
|
4
|
+
export { manualClock } from './clock.js';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type JWK } from 'jose';
|
|
2
|
+
import type { FakeRun } from './run.js';
|
|
3
|
+
/** One notice attempt (`cloud.notices`). */
|
|
4
|
+
export interface FakeNotice {
|
|
5
|
+
runId: string;
|
|
6
|
+
userId: string;
|
|
7
|
+
/** 1–3. */
|
|
8
|
+
attempt: number;
|
|
9
|
+
/** Whether this request followed a 401 within the same attempt. */
|
|
10
|
+
remint: boolean;
|
|
11
|
+
latestSequence: number;
|
|
12
|
+
/** The decoded token payload. */
|
|
13
|
+
claims: Record<string, unknown>;
|
|
14
|
+
/** The app's status, or null when `deliver` threw. */
|
|
15
|
+
status: number | null;
|
|
16
|
+
/** The thrown message, or null. */
|
|
17
|
+
error: string | null;
|
|
18
|
+
at: number;
|
|
19
|
+
}
|
|
20
|
+
export interface RunNoticeOptions {
|
|
21
|
+
/** The app's registered `run_notice_url`: the token's `aud` and the POST target. */
|
|
22
|
+
url: string;
|
|
23
|
+
/** The app's route handler. */
|
|
24
|
+
deliver: (request: Request) => Promise<Response>;
|
|
25
|
+
/** Waits before attempts 2 and 3 (daemon: `[3_000, 5_000]`). Default `[0, 0]`. */
|
|
26
|
+
retryWaitsMs?: number[];
|
|
27
|
+
/** Gap after a delivered notice (daemon: 5_000). Default 0. */
|
|
28
|
+
ackGapMs?: number;
|
|
29
|
+
/** Gap after a dropped notice (daemon: 60_000). Default 0. */
|
|
30
|
+
failGapMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/** The cloud's one ES256 key: unique `kid` per instance. */
|
|
33
|
+
export declare class SigningKey {
|
|
34
|
+
readonly kid: string;
|
|
35
|
+
private key;
|
|
36
|
+
private load;
|
|
37
|
+
jwks(): Promise<{
|
|
38
|
+
keys: JWK[];
|
|
39
|
+
}>;
|
|
40
|
+
sign(claims: Record<string, unknown>): Promise<string>;
|
|
41
|
+
}
|
|
42
|
+
/** The outbox: owed, coalesced, retried and re-minted as the daemon's. */
|
|
43
|
+
export declare class RunNotices {
|
|
44
|
+
private readonly options;
|
|
45
|
+
private readonly key;
|
|
46
|
+
private readonly identity;
|
|
47
|
+
/** Whether the run's person holds an active grant: a revoked grant has no notice target (`runNoticeTarget`). */
|
|
48
|
+
private readonly grantActive;
|
|
49
|
+
/** Token claims and records read this clock; the outbox's gaps and retries stay on real timers. */
|
|
50
|
+
private readonly clock;
|
|
51
|
+
private readonly states;
|
|
52
|
+
readonly records: FakeNotice[];
|
|
53
|
+
constructor(options: RunNoticeOptions | undefined, key: SigningKey, identity: {
|
|
54
|
+
issuer: string;
|
|
55
|
+
clientId: string;
|
|
56
|
+
runtimeId: string;
|
|
57
|
+
runtimeClientId: string;
|
|
58
|
+
},
|
|
59
|
+
/** Whether the run's person holds an active grant: a revoked grant has no notice target (`runNoticeTarget`). */
|
|
60
|
+
grantActive: (userId: string) => boolean,
|
|
61
|
+
/** Token claims and records read this clock; the outbox's gaps and retries stay on real timers. */
|
|
62
|
+
clock: {
|
|
63
|
+
now(): number;
|
|
64
|
+
});
|
|
65
|
+
private state;
|
|
66
|
+
/** An open stream satisfies anything owed; closing it with the log ahead of what it wrote owes a notice. */
|
|
67
|
+
streamOpened(run: FakeRun): (lastWritten: number) => void;
|
|
68
|
+
appended(run: FakeRun): void;
|
|
69
|
+
forget(run: FakeRun): void;
|
|
70
|
+
reset(): void;
|
|
71
|
+
private owe;
|
|
72
|
+
private schedule;
|
|
73
|
+
private deliver;
|
|
74
|
+
private attempt;
|
|
75
|
+
private mint;
|
|
76
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Runtime→app run notices, mirroring `RunNoticeOutbox` (src/daemon/run-notices.ts) rule for rule, and the
|
|
2
|
+
// cloud's ES256 signing key that mints their tokens and is served as the directory JWKS.
|
|
3
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
+
import { SignJWT, decodeJwt, exportJWK, generateKeyPair } from 'jose';
|
|
5
|
+
/** The daemon's per-attempt bound (`RUN_NOTICE_ATTEMPT_TIMEOUT_MS`, src/daemon/run-notices.ts). */
|
|
6
|
+
const ATTEMPT_TIMEOUT_MS = 10_000;
|
|
7
|
+
let keySerial = 0;
|
|
8
|
+
/** The cloud's one ES256 key: unique `kid` per instance. */
|
|
9
|
+
export class SigningKey {
|
|
10
|
+
kid = `fake-${++keySerial}-${randomBytes(4).toString('hex')}`;
|
|
11
|
+
key;
|
|
12
|
+
load() {
|
|
13
|
+
this.key ??= (async () => {
|
|
14
|
+
const { privateKey, publicKey } = await generateKeyPair('ES256', { extractable: true });
|
|
15
|
+
const jwk = await exportJWK(publicKey);
|
|
16
|
+
return { privateKey: privateKey, jwk: { ...jwk, kid: this.kid, alg: 'ES256', use: 'sig' } };
|
|
17
|
+
})();
|
|
18
|
+
return this.key;
|
|
19
|
+
}
|
|
20
|
+
async jwks() { return { keys: [(await this.load()).jwk] }; }
|
|
21
|
+
async sign(claims) {
|
|
22
|
+
const { privateKey } = await this.load();
|
|
23
|
+
return new SignJWT(claims).setProtectedHeader({ alg: 'ES256', typ: 'at+jwt', kid: this.kid }).sign(privateKey);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
class Fault extends Error {
|
|
27
|
+
retryable;
|
|
28
|
+
constructor(retryable, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.retryable = retryable;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The outbox: owed, coalesced, retried and re-minted as the daemon's. */
|
|
34
|
+
export class RunNotices {
|
|
35
|
+
options;
|
|
36
|
+
key;
|
|
37
|
+
identity;
|
|
38
|
+
grantActive;
|
|
39
|
+
clock;
|
|
40
|
+
states = new Map();
|
|
41
|
+
records = [];
|
|
42
|
+
constructor(options, key, identity,
|
|
43
|
+
/** Whether the run's person holds an active grant: a revoked grant has no notice target (`runNoticeTarget`). */
|
|
44
|
+
grantActive,
|
|
45
|
+
/** Token claims and records read this clock; the outbox's gaps and retries stay on real timers. */
|
|
46
|
+
clock) {
|
|
47
|
+
this.options = options;
|
|
48
|
+
this.key = key;
|
|
49
|
+
this.identity = identity;
|
|
50
|
+
this.grantActive = grantActive;
|
|
51
|
+
this.clock = clock;
|
|
52
|
+
}
|
|
53
|
+
state(run) {
|
|
54
|
+
let state = this.states.get(run);
|
|
55
|
+
if (!state) {
|
|
56
|
+
state = { streams: 0, owed: false, inFlight: false, nextAllowedAt: 0 };
|
|
57
|
+
this.states.set(run, state);
|
|
58
|
+
}
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
61
|
+
/** An open stream satisfies anything owed; closing it with the log ahead of what it wrote owes a notice. */
|
|
62
|
+
streamOpened(run) {
|
|
63
|
+
const state = this.state(run);
|
|
64
|
+
state.streams++;
|
|
65
|
+
state.owed = false;
|
|
66
|
+
state.nextAllowedAt = 0;
|
|
67
|
+
if (state.timer) {
|
|
68
|
+
clearTimeout(state.timer);
|
|
69
|
+
state.timer = undefined;
|
|
70
|
+
}
|
|
71
|
+
let released = false;
|
|
72
|
+
return (lastWritten) => {
|
|
73
|
+
if (released)
|
|
74
|
+
return;
|
|
75
|
+
released = true;
|
|
76
|
+
state.streams--;
|
|
77
|
+
if (state.streams === 0 && run.latestSequence > lastWritten && !run.deleted)
|
|
78
|
+
this.owe(run);
|
|
79
|
+
else if (state.streams === 0 && !state.owed && !state.inFlight)
|
|
80
|
+
this.forget(run);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
appended(run) {
|
|
84
|
+
if (!this.options)
|
|
85
|
+
return;
|
|
86
|
+
if (this.state(run).streams > 0)
|
|
87
|
+
return;
|
|
88
|
+
this.owe(run);
|
|
89
|
+
}
|
|
90
|
+
forget(run) {
|
|
91
|
+
const state = this.states.get(run);
|
|
92
|
+
if (state?.timer)
|
|
93
|
+
clearTimeout(state.timer);
|
|
94
|
+
this.states.delete(run);
|
|
95
|
+
}
|
|
96
|
+
reset() {
|
|
97
|
+
for (const state of this.states.values())
|
|
98
|
+
if (state.timer)
|
|
99
|
+
clearTimeout(state.timer);
|
|
100
|
+
this.states.clear();
|
|
101
|
+
this.records.length = 0;
|
|
102
|
+
}
|
|
103
|
+
owe(run) {
|
|
104
|
+
if (!this.options || !this.grantActive(run.userId))
|
|
105
|
+
return;
|
|
106
|
+
const state = this.state(run);
|
|
107
|
+
state.owed = true;
|
|
108
|
+
this.schedule(run, state);
|
|
109
|
+
}
|
|
110
|
+
schedule(run, state) {
|
|
111
|
+
if (!state.owed || state.inFlight || state.timer || state.streams > 0)
|
|
112
|
+
return;
|
|
113
|
+
state.timer = setTimeout(() => { state.timer = undefined; void this.deliver(run, state); }, Math.max(0, state.nextAllowedAt - Date.now()));
|
|
114
|
+
state.timer.unref?.();
|
|
115
|
+
}
|
|
116
|
+
async deliver(run, state) {
|
|
117
|
+
const options = this.options;
|
|
118
|
+
if (!state.owed || state.inFlight || state.streams > 0 || run.deleted)
|
|
119
|
+
return;
|
|
120
|
+
if (!this.grantActive(run.userId)) {
|
|
121
|
+
state.owed = false;
|
|
122
|
+
this.forget(run);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
state.owed = false;
|
|
126
|
+
state.inFlight = true;
|
|
127
|
+
const latest = run.latestSequence;
|
|
128
|
+
const waits = options.retryWaitsMs ?? [0, 0];
|
|
129
|
+
let delivered = false;
|
|
130
|
+
let satisfied = false;
|
|
131
|
+
for (let attempt = 1;; attempt++) {
|
|
132
|
+
try {
|
|
133
|
+
await this.attempt(run, attempt, latest);
|
|
134
|
+
delivered = true;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (!(error instanceof Fault) || !error.retryable)
|
|
139
|
+
break;
|
|
140
|
+
const wait = waits[attempt - 1];
|
|
141
|
+
if (wait === undefined)
|
|
142
|
+
break;
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
144
|
+
if (state.streams > 0) {
|
|
145
|
+
satisfied = true;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
state.inFlight = false;
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
if (!delivered && !satisfied)
|
|
153
|
+
state.nextAllowedAt = now + (options.failGapMs ?? 0);
|
|
154
|
+
else if (delivered && state.streams === 0)
|
|
155
|
+
state.nextAllowedAt = now + (options.ackGapMs ?? 0);
|
|
156
|
+
if (state.owed)
|
|
157
|
+
this.schedule(run, state);
|
|
158
|
+
else if (state.streams === 0 && this.states.get(run) === state && !state.timer && now >= state.nextAllowedAt)
|
|
159
|
+
this.states.delete(run);
|
|
160
|
+
}
|
|
161
|
+
async attempt(run, attempt, latest) {
|
|
162
|
+
const options = this.options;
|
|
163
|
+
for (let remint = false;; remint = true) {
|
|
164
|
+
const token = await this.mint(run);
|
|
165
|
+
const record = { runId: run.id, userId: run.userId, attempt, remint, latestSequence: latest, claims: decodeJwt(token), status: null, error: null, at: this.clock.now() };
|
|
166
|
+
this.records.push(record);
|
|
167
|
+
let response;
|
|
168
|
+
// Each request is bounded as the daemon's (`RUN_NOTICE_ATTEMPT_TIMEOUT_MS`): a hung handler is a retryable failure.
|
|
169
|
+
const signal = AbortSignal.timeout(ATTEMPT_TIMEOUT_MS);
|
|
170
|
+
let timer;
|
|
171
|
+
try {
|
|
172
|
+
response = await Promise.race([
|
|
173
|
+
options.deliver(new Request(options.url, {
|
|
174
|
+
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
175
|
+
body: JSON.stringify({ run_id: run.id, latest_sequence: latest }), signal,
|
|
176
|
+
})),
|
|
177
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`no answer within ${ATTEMPT_TIMEOUT_MS}ms`)), ATTEMPT_TIMEOUT_MS); timer.unref?.(); }),
|
|
178
|
+
]);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
record.error = error instanceof Error ? error.message : String(error);
|
|
182
|
+
throw new Fault(true, record.error);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
}
|
|
187
|
+
record.status = response.status;
|
|
188
|
+
if (response.status >= 200 && response.status < 300)
|
|
189
|
+
return;
|
|
190
|
+
if (response.status === 401) {
|
|
191
|
+
if (!remint)
|
|
192
|
+
continue;
|
|
193
|
+
throw new Fault(true, 'unauthorized after re-mint');
|
|
194
|
+
}
|
|
195
|
+
if (response.status >= 500)
|
|
196
|
+
throw new Fault(true, `status ${response.status}`);
|
|
197
|
+
throw new Fault(false, `status ${response.status}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
mint(run) {
|
|
201
|
+
const now = Math.floor(this.clock.now() / 1000);
|
|
202
|
+
const app = `app:${this.identity.clientId}`;
|
|
203
|
+
return this.key.sign({
|
|
204
|
+
iss: this.identity.issuer, aud: this.options.url, sub: run.userId, grantee: app, client_id: this.identity.runtimeClientId,
|
|
205
|
+
scope: 'crtr:run_notice', initiating_app: app, act: { sub: this.identity.runtimeId, act: { sub: app } },
|
|
206
|
+
iat: now, exp: now + 300, jti: randomUUID(),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { DeclinedResultDTO, RunActivityDTO, RunEvent, RunEventFields, RunEventType, RunOutcomeDTO, RunQuestionSlotDTO, RunStatusDTO, TurnErrorDTO } from '@north-light/api';
|
|
2
|
+
/** What a FakeRun needs from the cloud that owns it. */
|
|
3
|
+
export interface RunHost {
|
|
4
|
+
readonly grantee: string;
|
|
5
|
+
readonly streamIdleMs: number;
|
|
6
|
+
/** The cloud's clock, in ms: every timestamp the run writes reads it. */
|
|
7
|
+
now(): number;
|
|
8
|
+
/** Whether the run owner's grant is active now. */
|
|
9
|
+
grantActive(userId: string): boolean;
|
|
10
|
+
/** An event was appended (run-notice outbox). */
|
|
11
|
+
appended(run: FakeRun): void;
|
|
12
|
+
/** A stream opened; the returned function is called with the last sequence it wrote when it closes. */
|
|
13
|
+
streamOpened(run: FakeRun): (lastWritten: number) => void;
|
|
14
|
+
/** The run was deleted: drop its idempotency keys. */
|
|
15
|
+
deleted(run: FakeRun): void;
|
|
16
|
+
}
|
|
17
|
+
/** One message the run accepted (`run.start` `message` or `run.message`). */
|
|
18
|
+
export interface FakeRunMessage {
|
|
19
|
+
message_id: string;
|
|
20
|
+
text: string;
|
|
21
|
+
start_turn: boolean;
|
|
22
|
+
sequence_number: number;
|
|
23
|
+
idempotencyKey: string | null;
|
|
24
|
+
delivered: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** The `run.start` body as the fake received it. */
|
|
27
|
+
export interface FakeRunParams {
|
|
28
|
+
prompt: string;
|
|
29
|
+
message?: string;
|
|
30
|
+
scopes?: string[];
|
|
31
|
+
isolated?: boolean;
|
|
32
|
+
/** An isolated run's working folder, when the app named one. */
|
|
33
|
+
cwd?: string;
|
|
34
|
+
output_schema?: Record<string, unknown>;
|
|
35
|
+
metadata?: Record<string, unknown>;
|
|
36
|
+
profile?: string;
|
|
37
|
+
}
|
|
38
|
+
/** How `settle` ends a run. */
|
|
39
|
+
export type FakeRunEnding = {
|
|
40
|
+
result: unknown;
|
|
41
|
+
} | {
|
|
42
|
+
declined: DeclinedResultDTO;
|
|
43
|
+
} | {
|
|
44
|
+
failure: 'canceled' | 'deadline_exceeded';
|
|
45
|
+
} | {
|
|
46
|
+
runtimeError: string;
|
|
47
|
+
};
|
|
48
|
+
/** A question `ask` opens; defaults to one text slot `answer`. */
|
|
49
|
+
export interface FakeQuestion {
|
|
50
|
+
title: string;
|
|
51
|
+
subtitle?: string;
|
|
52
|
+
steps?: number;
|
|
53
|
+
slots?: RunQuestionSlotDTO[];
|
|
54
|
+
provider?: string;
|
|
55
|
+
tool?: string;
|
|
56
|
+
}
|
|
57
|
+
interface OpenQuestion {
|
|
58
|
+
question_id: string;
|
|
59
|
+
title: string;
|
|
60
|
+
subtitle?: string;
|
|
61
|
+
steps: number;
|
|
62
|
+
slots: RunQuestionSlotDTO[];
|
|
63
|
+
status: 'open' | 'answered' | 'canceled';
|
|
64
|
+
answer?: Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
/** `activity.tool.summary` as the daemon writes it: a command's first line, else a path; null otherwise. */
|
|
67
|
+
export declare function toolCallSummary(args: unknown): string | null;
|
|
68
|
+
/** One run the app started, and the test's handle on it. */
|
|
69
|
+
export declare class FakeRun {
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly userId: string;
|
|
72
|
+
private readonly host;
|
|
73
|
+
private readonly metadata;
|
|
74
|
+
readonly scopes: string[] | null;
|
|
75
|
+
/** The run's start-time body, as received. */
|
|
76
|
+
readonly params: FakeRunParams;
|
|
77
|
+
/** The `Idempotency-Key` of the start, or null. */
|
|
78
|
+
readonly idempotencyKey: string | null;
|
|
79
|
+
/** Every message the run accepted; the start's `message` is the first, with `start_turn: false`. */
|
|
80
|
+
readonly messages: FakeRunMessage[];
|
|
81
|
+
name: string;
|
|
82
|
+
readonly createdAt: string;
|
|
83
|
+
lastActivityAt: string;
|
|
84
|
+
/** Kept events (`expireEvents` drops the oldest). */
|
|
85
|
+
private log;
|
|
86
|
+
private sequence;
|
|
87
|
+
private outcomeValue;
|
|
88
|
+
private resultValue;
|
|
89
|
+
private hasResult;
|
|
90
|
+
private settledAt;
|
|
91
|
+
private turnError;
|
|
92
|
+
private rested;
|
|
93
|
+
private answered;
|
|
94
|
+
private override;
|
|
95
|
+
private messageSerial;
|
|
96
|
+
private outcomeRevision;
|
|
97
|
+
private finalReport;
|
|
98
|
+
readonly questions: Map<string, OpenQuestion>;
|
|
99
|
+
private readonly streams;
|
|
100
|
+
private readonly waiters;
|
|
101
|
+
deleted: boolean;
|
|
102
|
+
constructor(id: string, userId: string, params: FakeRunParams, idempotencyKey: string | null, host: RunHost, metadata: Record<string, unknown>, scopes: string[] | null);
|
|
103
|
+
/** Every kept event, in order. */
|
|
104
|
+
get events(): readonly RunEvent[];
|
|
105
|
+
get latestSequence(): number;
|
|
106
|
+
get earliestSequence(): number | null;
|
|
107
|
+
get outcome(): RunOutcomeDTO | undefined;
|
|
108
|
+
get result(): unknown;
|
|
109
|
+
get lastTurnError(): TurnErrorDTO | undefined;
|
|
110
|
+
/** Messages not yet delivered to a turn (the start's `message` included). */
|
|
111
|
+
get pending(): FakeRunMessage[];
|
|
112
|
+
get pendingQuestionIds(): string[];
|
|
113
|
+
/** The run status, derived as the daemon's `runStatus` derives it. */
|
|
114
|
+
get status(): RunStatusDTO;
|
|
115
|
+
/** What the root is doing, derived from the event log as `runActivity` derives it with no open streaming block. */
|
|
116
|
+
get activity(): RunActivityDTO | null;
|
|
117
|
+
/** `run.get`'s body. */
|
|
118
|
+
toObject(): Record<string, unknown>;
|
|
119
|
+
/** Append any run event; `nodeId` defaults to the root (another id is a child node's event). */
|
|
120
|
+
emit<T extends RunEventType>(type: T, fields: RunEventFields<T>, { nodeId }?: {
|
|
121
|
+
nodeId?: string;
|
|
122
|
+
}): RunEvent;
|
|
123
|
+
/** One turn answering `text`: turn started, `chunks` deltas, text done, turn completed. Delivers `messageIds`
|
|
124
|
+
* (default: every pending message). */
|
|
125
|
+
reply(text: string, { chunks, messageIds }?: {
|
|
126
|
+
chunks?: number;
|
|
127
|
+
messageIds?: string[];
|
|
128
|
+
}): void;
|
|
129
|
+
/** The same turn ending in error: `last_turn_error` set, status idle. */
|
|
130
|
+
failTurn({ code, message, messageIds }: {
|
|
131
|
+
code: TurnErrorDTO['code'];
|
|
132
|
+
message: string;
|
|
133
|
+
messageIds?: string[];
|
|
134
|
+
}): void;
|
|
135
|
+
/** Fake-only: status `running` with `activity` overridden until the next appended event. */
|
|
136
|
+
setActivity(activity: RunActivityDTO, { at }?: {
|
|
137
|
+
at?: string | Date | number;
|
|
138
|
+
}): void;
|
|
139
|
+
/** The run goes idle without a new turn (it stopped without submitting); undelivered start-turn mail is consumed. */
|
|
140
|
+
idle(): void;
|
|
141
|
+
/** Record the run's ending and emit `node.settled`. */
|
|
142
|
+
settle(ending: FakeRunEnding): void;
|
|
143
|
+
/** Open a question: a `waiting_on_user` event; the run waits on the user between turns. Returns its id. */
|
|
144
|
+
ask(question: FakeQuestion): string;
|
|
145
|
+
/** Delete the run as the runtime does: reads answer 404, streams close, its idempotency keys are dropped. */
|
|
146
|
+
delete(): void;
|
|
147
|
+
/** Idle-close every open stream now. */
|
|
148
|
+
closeIdle(): void;
|
|
149
|
+
/** Drop kept events up to `upTo` (event expiry; makes `stream_gap` reachable). */
|
|
150
|
+
expireEvents(upTo: number): void;
|
|
151
|
+
/** Record an accepted message; returns it. */
|
|
152
|
+
accept(text: string, startTurn: boolean, idempotencyKey: string | null): FakeRunMessage;
|
|
153
|
+
/** Clear a non-canceled ending so a start-turn message runs it again (`commitReopenResident`). */
|
|
154
|
+
reopen(): void;
|
|
155
|
+
/** The app answered a question. */
|
|
156
|
+
answer(questionId: string, responses: Record<string, unknown>): void;
|
|
157
|
+
questionObject(questionId: string): Record<string, unknown> | undefined;
|
|
158
|
+
/** Resolves when the run rests (`idle`, `waiting_on_user`, `settled`), is deleted, `seconds` pass, or `signal` aborts. */
|
|
159
|
+
waitResting(seconds: number, signal?: AbortSignal | null): Promise<void>;
|
|
160
|
+
/** The SSE body after `after`, as `streamRunEvents` writes it. Validation happens before the caller calls this. */
|
|
161
|
+
openStream(after: number, signal?: AbortSignal | null): ReadableStream<Uint8Array>;
|
|
162
|
+
/** The grant went inactive: every open stream gets the 401 `grant_revoked` error frame. */
|
|
163
|
+
revokeStreams(): void;
|
|
164
|
+
get openStreams(): number;
|
|
165
|
+
/** Notify held reads that state changed. */
|
|
166
|
+
wake(): void;
|
|
167
|
+
private assertLive;
|
|
168
|
+
private turnOpen;
|
|
169
|
+
private everStartedTurn;
|
|
170
|
+
private turnsStarted;
|
|
171
|
+
private turn;
|
|
172
|
+
private append;
|
|
173
|
+
}
|
|
174
|
+
export {};
|