@north-light/sdk 0.0.0 → 0.3.414
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 +162 -0
- package/dist/client.js +543 -0
- package/dist/error-codes.d.ts +7 -0
- package/dist/error-codes.js +41 -0
- package/dist/errors.d.ts +57 -0
- package/dist/errors.js +87 -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 +6 -0
- package/dist/keygen-command.d.ts +6 -0
- package/dist/keygen-command.js +157 -0
- package/dist/oauth/index.d.ts +217 -0
- package/dist/oauth/index.js +535 -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 +7 -0
- package/dist/resources/request.js +19 -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 +192 -0
- package/dist/resources/runs.js +208 -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/cloud.d.ts +209 -0
- package/dist/testing/cloud.js +880 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +3 -0
- package/dist/testing/notices.d.ts +70 -0
- package/dist/testing/notices.js +205 -0
- package/dist/testing/run.d.ts +172 -0
- package/dist/testing/run.js +429 -0
- package/dist/testing/users.d.ts +121 -0
- package/dist/testing/users.js +70 -0
- package/dist/testing/wire.d.ts +62 -0
- package/dist/testing/wire.js +148 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/package.json +56 -5
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { envelopeBody, fakeError, nowIso } from './wire.js';
|
|
2
|
+
const usage = () => ({ input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, cost_usd: 0 });
|
|
3
|
+
const SUMMARY_MAX = 120;
|
|
4
|
+
const KEEPALIVE_MS = 15_000;
|
|
5
|
+
const BACKLOG_MAX_EVENTS = 1000;
|
|
6
|
+
const BACKLOG_MAX_BYTES = 32 * 1024 * 1024;
|
|
7
|
+
const SUBMIT_RESULT_TOOL = 'submit_result';
|
|
8
|
+
/** `activity.tool.summary` as the daemon writes it: a command's first line, else a path; null otherwise. */
|
|
9
|
+
export function toolCallSummary(args) {
|
|
10
|
+
if (!args || typeof args !== 'object')
|
|
11
|
+
return null;
|
|
12
|
+
const fields = args;
|
|
13
|
+
const raw = [fields['command'], fields['file_path'], fields['path']].find((value) => typeof value === 'string' && value.trim() !== '');
|
|
14
|
+
if (raw === undefined)
|
|
15
|
+
return null;
|
|
16
|
+
const line = raw.trim().split('\n')[0].trim();
|
|
17
|
+
return line.length > SUMMARY_MAX ? `${line.slice(0, SUMMARY_MAX - 1)}…` : line;
|
|
18
|
+
}
|
|
19
|
+
let entrySerial = 0;
|
|
20
|
+
const entryId = () => `entry-${++entrySerial}`;
|
|
21
|
+
/** One run the app started, and the test's handle on it. */
|
|
22
|
+
export class FakeRun {
|
|
23
|
+
id;
|
|
24
|
+
userId;
|
|
25
|
+
host;
|
|
26
|
+
metadata;
|
|
27
|
+
scopes;
|
|
28
|
+
/** The run's start-time body, as received. */
|
|
29
|
+
params;
|
|
30
|
+
/** The `Idempotency-Key` of the start, or null. */
|
|
31
|
+
idempotencyKey;
|
|
32
|
+
/** Every message the run accepted; the start's `message` is the first, with `start_turn: false`. */
|
|
33
|
+
messages = [];
|
|
34
|
+
name;
|
|
35
|
+
createdAt;
|
|
36
|
+
lastActivityAt;
|
|
37
|
+
/** Kept events (`expireEvents` drops the oldest). */
|
|
38
|
+
log = [];
|
|
39
|
+
sequence = 0;
|
|
40
|
+
outcomeValue;
|
|
41
|
+
resultValue;
|
|
42
|
+
hasResult = false;
|
|
43
|
+
settledAt;
|
|
44
|
+
turnError;
|
|
45
|
+
rested = false;
|
|
46
|
+
answered = false;
|
|
47
|
+
override;
|
|
48
|
+
messageSerial = 0;
|
|
49
|
+
outcomeRevision = 0;
|
|
50
|
+
finalReport;
|
|
51
|
+
questions = new Map();
|
|
52
|
+
streams = new Set();
|
|
53
|
+
waiters = new Set();
|
|
54
|
+
deleted = false;
|
|
55
|
+
constructor(id, userId, params, idempotencyKey, host, metadata, scopes) {
|
|
56
|
+
this.id = id;
|
|
57
|
+
this.userId = userId;
|
|
58
|
+
this.host = host;
|
|
59
|
+
this.metadata = metadata;
|
|
60
|
+
this.scopes = scopes;
|
|
61
|
+
this.params = params;
|
|
62
|
+
this.idempotencyKey = idempotencyKey;
|
|
63
|
+
this.name = id;
|
|
64
|
+
this.createdAt = nowIso();
|
|
65
|
+
this.lastActivityAt = this.createdAt;
|
|
66
|
+
}
|
|
67
|
+
// ---- reads ---------------------------------------------------------------------------------------------------
|
|
68
|
+
/** Every kept event, in order. */
|
|
69
|
+
get events() { return this.log; }
|
|
70
|
+
get latestSequence() { return this.sequence; }
|
|
71
|
+
get earliestSequence() { return this.log[0]?.sequence_number ?? null; }
|
|
72
|
+
get outcome() { return this.outcomeValue; }
|
|
73
|
+
get result() { return this.resultValue; }
|
|
74
|
+
get lastTurnError() { return this.turnError; }
|
|
75
|
+
/** Messages not yet delivered to a turn (the start's `message` included). */
|
|
76
|
+
get pending() { return this.messages.filter((message) => !message.delivered); }
|
|
77
|
+
get pendingQuestionIds() { return [...this.questions.values()].filter((q) => q.status === 'open').map((q) => q.question_id); }
|
|
78
|
+
/** The run status, derived as the daemon's `runStatus` derives it. */
|
|
79
|
+
get status() {
|
|
80
|
+
if (this.outcomeValue)
|
|
81
|
+
return 'settled';
|
|
82
|
+
if (this.override || this.turnOpen() || this.answered || !this.rested || this.messages.some((m) => m.start_turn && !m.delivered))
|
|
83
|
+
return 'running';
|
|
84
|
+
return this.pendingQuestionIds.length ? 'waiting_on_user' : 'idle';
|
|
85
|
+
}
|
|
86
|
+
/** What the root is doing, derived from the event log as `runActivity` derives it with no open streaming block. */
|
|
87
|
+
get activity() {
|
|
88
|
+
if (this.status !== 'running')
|
|
89
|
+
return null;
|
|
90
|
+
if (this.override)
|
|
91
|
+
return this.override;
|
|
92
|
+
const turns = this.log.filter((e) => e.node_id === this.id && (e.type === 'node.turn.started' || e.type === 'node.turn.completed'));
|
|
93
|
+
const turn = turns.at(-1);
|
|
94
|
+
if (!turn)
|
|
95
|
+
return this.everStartedTurn() ? { phase: 'between_turns' } : { phase: 'starting' };
|
|
96
|
+
if (turn.type === 'node.turn.completed')
|
|
97
|
+
return { phase: 'between_turns' };
|
|
98
|
+
const calls = this.log.filter((e) => e.node_id === this.id && e.sequence_number > turn.sequence_number &&
|
|
99
|
+
(e.type === 'node.tool_call.started' || e.type === 'node.tool_call.completed'));
|
|
100
|
+
const done = new Set(calls.filter((c) => c.type === 'node.tool_call.completed').map((c) => c.tool_call_id));
|
|
101
|
+
const running = calls.filter((c) => c.type === 'node.tool_call.started' && !done.has(c.tool_call_id)).at(-1);
|
|
102
|
+
if (running) {
|
|
103
|
+
if (running.name === SUBMIT_RESULT_TOOL)
|
|
104
|
+
return { phase: 'submitting_result' };
|
|
105
|
+
return { phase: 'tool', tool: { name: running.name, summary: toolCallSummary(running.arguments ?? null) } };
|
|
106
|
+
}
|
|
107
|
+
return { phase: 'thinking' };
|
|
108
|
+
}
|
|
109
|
+
/** `run.get`'s body. */
|
|
110
|
+
toObject() {
|
|
111
|
+
const status = this.status;
|
|
112
|
+
return {
|
|
113
|
+
run_id: this.id, name: this.name, status,
|
|
114
|
+
...(this.outcomeValue ? { outcome: this.outcomeValue } : {}),
|
|
115
|
+
...(this.outcomeValue && this.hasResult ? { result: this.resultValue } : {}),
|
|
116
|
+
// `last_report` is the newest report body (`runObject`); a scripted ending writes no report, so it is absent.
|
|
117
|
+
pending_question_ids: status === 'settled' ? [] : this.pendingQuestionIds,
|
|
118
|
+
...(this.turnError ? { last_turn_error: this.turnError } : {}),
|
|
119
|
+
usage: usage(), payer: 'user', scopes: this.scopes, metadata: this.metadata, created_at: this.createdAt,
|
|
120
|
+
...(this.settledAt ? { settled_at: this.settledAt } : {}),
|
|
121
|
+
latest_sequence: this.sequence, last_activity_at: this.lastActivityAt, activity: this.activity,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// ---- scripting -------------------------------------------------------------------------------------------------
|
|
125
|
+
/** Append any run event; `nodeId` defaults to the root (another id is a child node's event). */
|
|
126
|
+
emit(type, fields, { nodeId } = {}) {
|
|
127
|
+
this.assertLive();
|
|
128
|
+
return this.append(type, fields, nodeId);
|
|
129
|
+
}
|
|
130
|
+
/** One turn answering `text`: turn started, `chunks` deltas, text done, turn completed. Delivers `messageIds`
|
|
131
|
+
* (default: every pending message). */
|
|
132
|
+
reply(text, { chunks = 2, messageIds } = {}) {
|
|
133
|
+
this.turn(messageIds, (ids) => {
|
|
134
|
+
for (const piece of split(text, chunks))
|
|
135
|
+
this.append('node.output_text.delta', { delta: piece });
|
|
136
|
+
this.append('node.output_text.done', { text, stop_reason: 'stop', entry_id: entryId() });
|
|
137
|
+
this.turnError = undefined;
|
|
138
|
+
return { stop_reason: 'stop', ids };
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** The same turn ending in error: `last_turn_error` set, status idle. */
|
|
142
|
+
failTurn({ code, message, messageIds }) {
|
|
143
|
+
this.turn(messageIds, (ids) => {
|
|
144
|
+
this.append('node.output_text.done', { text: '', stop_reason: 'error', entry_id: entryId() });
|
|
145
|
+
this.turnError = { code, message };
|
|
146
|
+
return { stop_reason: 'error', ids, error: { code, message } };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/** Fake-only: status `running` with `activity` overridden until the next appended event. */
|
|
150
|
+
setActivity(activity, { at } = {}) {
|
|
151
|
+
this.assertLive();
|
|
152
|
+
this.override = activity;
|
|
153
|
+
this.lastActivityAt = at === undefined ? nowIso() : new Date(at).toISOString();
|
|
154
|
+
this.wake();
|
|
155
|
+
}
|
|
156
|
+
/** The run goes idle without a new turn (it stopped without submitting); undelivered start-turn mail is consumed. */
|
|
157
|
+
idle() {
|
|
158
|
+
this.assertLive();
|
|
159
|
+
this.rested = true;
|
|
160
|
+
this.answered = false;
|
|
161
|
+
this.override = undefined;
|
|
162
|
+
for (const message of this.messages)
|
|
163
|
+
if (message.start_turn)
|
|
164
|
+
message.delivered = true;
|
|
165
|
+
this.lastActivityAt = nowIso();
|
|
166
|
+
this.wake();
|
|
167
|
+
}
|
|
168
|
+
/** Record the run's ending and emit `node.settled`. */
|
|
169
|
+
settle(ending) {
|
|
170
|
+
this.assertLive();
|
|
171
|
+
if (this.outcomeValue)
|
|
172
|
+
throw new Error(`run ${this.id} is already settled`);
|
|
173
|
+
const settledAt = nowIso();
|
|
174
|
+
const base = { node_id: this.id, revision: ++this.outcomeRevision, settled_at: settledAt, structured_result: null, detail: null };
|
|
175
|
+
let outcome;
|
|
176
|
+
this.hasResult = false;
|
|
177
|
+
if ('result' in ending) {
|
|
178
|
+
this.finalReport = `${this.id}/reports/final`;
|
|
179
|
+
outcome = { ...base, final_report: this.finalReport, structured_result: ending.result ?? null, kind: 'result', reason: 'finalized', declined: null };
|
|
180
|
+
this.resultValue = ending.result;
|
|
181
|
+
this.hasResult = ending.result !== undefined;
|
|
182
|
+
}
|
|
183
|
+
else if ('declined' in ending) {
|
|
184
|
+
this.finalReport = `${this.id}/reports/final`;
|
|
185
|
+
outcome = { ...base, final_report: this.finalReport, kind: 'failure', reason: 'declined', declined: ending.declined, details: ending.declined };
|
|
186
|
+
}
|
|
187
|
+
else if ('failure' in ending) {
|
|
188
|
+
outcome = { ...base, final_report: null, kind: 'failure', reason: ending.failure, declined: null };
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
outcome = { ...base, final_report: null, kind: 'failure', reason: 'runtime_error', declined: null, details: { crouter_reason: ending.runtimeError } };
|
|
192
|
+
}
|
|
193
|
+
this.outcomeValue = outcome;
|
|
194
|
+
this.settledAt = settledAt;
|
|
195
|
+
this.override = undefined;
|
|
196
|
+
for (const question of this.questions.values())
|
|
197
|
+
if (question.status === 'open')
|
|
198
|
+
question.status = 'canceled';
|
|
199
|
+
this.append('node.settled', { outcome, usage: usage(), ...(this.hasResult ? { result: this.resultValue } : {}) });
|
|
200
|
+
}
|
|
201
|
+
/** Open a question: a `waiting_on_user` event; the run waits on the user between turns. Returns its id. */
|
|
202
|
+
ask(question) {
|
|
203
|
+
this.assertLive();
|
|
204
|
+
const id = `question-${this.id}-${this.questions.size + 1}`;
|
|
205
|
+
const slots = question.slots ?? [{ id: 'answer', kind: 'text', step: 0, config: {} }];
|
|
206
|
+
const steps = question.steps ?? 1;
|
|
207
|
+
this.questions.set(id, { question_id: id, title: question.title, ...(question.subtitle === undefined ? {} : { subtitle: question.subtitle }), steps, slots, status: 'open' });
|
|
208
|
+
this.rested = true;
|
|
209
|
+
this.append('waiting_on_user', {
|
|
210
|
+
question_id: id, initiating_app: this.host.grantee, title: question.title,
|
|
211
|
+
...(question.subtitle === undefined ? {} : { subtitle: question.subtitle }),
|
|
212
|
+
...(question.provider === undefined ? {} : { provider: question.provider }),
|
|
213
|
+
...(question.tool === undefined ? {} : { tool: question.tool }),
|
|
214
|
+
steps, slots,
|
|
215
|
+
});
|
|
216
|
+
return id;
|
|
217
|
+
}
|
|
218
|
+
/** Delete the run as the runtime does: reads answer 404, streams close, its idempotency keys are dropped. */
|
|
219
|
+
delete() {
|
|
220
|
+
if (this.deleted)
|
|
221
|
+
return;
|
|
222
|
+
this.deleted = true;
|
|
223
|
+
// The listener's next read finds no run: open streams get the 404 error frame, then close.
|
|
224
|
+
for (const stream of [...this.streams])
|
|
225
|
+
stream.error(fakeError({ status: 404, code: 'not_found', message: 'run not found' }));
|
|
226
|
+
this.host.deleted(this);
|
|
227
|
+
this.wake();
|
|
228
|
+
}
|
|
229
|
+
/** Idle-close every open stream now. */
|
|
230
|
+
closeIdle() { for (const stream of [...this.streams])
|
|
231
|
+
stream.idle(); }
|
|
232
|
+
/** Drop kept events up to `upTo` (event expiry; makes `stream_gap` reachable). */
|
|
233
|
+
expireEvents(upTo) { this.log = this.log.filter((event) => event.sequence_number > upTo); }
|
|
234
|
+
// ---- runtime side (used by the fake runtime) ---------------------------------------------------------------------
|
|
235
|
+
/** Record an accepted message; returns it. */
|
|
236
|
+
accept(text, startTurn, idempotencyKey) {
|
|
237
|
+
const message_id = `msg-${this.id}-${++this.messageSerial}`;
|
|
238
|
+
const event = this.append('node.message.accepted', { message_id, start_turn: startTurn });
|
|
239
|
+
const message = { message_id, text, start_turn: startTurn, sequence_number: event.sequence_number, idempotencyKey, delivered: false };
|
|
240
|
+
this.messages.push(message);
|
|
241
|
+
this.wake();
|
|
242
|
+
return message;
|
|
243
|
+
}
|
|
244
|
+
/** Clear a non-canceled ending so a start-turn message runs it again (`commitReopenResident`). */
|
|
245
|
+
reopen() {
|
|
246
|
+
this.outcomeValue = undefined;
|
|
247
|
+
this.resultValue = undefined;
|
|
248
|
+
this.hasResult = false;
|
|
249
|
+
this.settledAt = undefined;
|
|
250
|
+
}
|
|
251
|
+
/** The app answered a question. */
|
|
252
|
+
answer(questionId, responses) {
|
|
253
|
+
const question = this.questions.get(questionId);
|
|
254
|
+
question.status = 'answered';
|
|
255
|
+
question.answer = responses;
|
|
256
|
+
this.answered = true;
|
|
257
|
+
this.append('question.answered', { question_id: questionId, answer: responses });
|
|
258
|
+
}
|
|
259
|
+
questionObject(questionId) {
|
|
260
|
+
const q = this.questions.get(questionId);
|
|
261
|
+
if (!q)
|
|
262
|
+
return undefined;
|
|
263
|
+
return {
|
|
264
|
+
question_id: q.question_id, initiating_app: this.host.grantee, title: q.title, ...(q.subtitle === undefined ? {} : { subtitle: q.subtitle }),
|
|
265
|
+
steps: q.steps, slots: q.slots, run_id: this.id, node_id: this.id, status: q.status, ...(q.answer ? { answer: q.answer } : {}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** Resolves when the run rests (`idle`, `waiting_on_user`, `settled`), is deleted, `seconds` pass, or `signal` aborts. */
|
|
269
|
+
async waitResting(seconds, signal) {
|
|
270
|
+
const resting = () => this.deleted || this.status !== 'running';
|
|
271
|
+
if (!seconds || resting())
|
|
272
|
+
return;
|
|
273
|
+
await new Promise((resolve, reject) => {
|
|
274
|
+
const finish = () => { clearTimeout(timer); this.waiters.delete(check); signal?.removeEventListener('abort', onAbort); resolve(); };
|
|
275
|
+
const check = () => { if (resting())
|
|
276
|
+
finish(); };
|
|
277
|
+
const onAbort = () => { clearTimeout(timer); this.waiters.delete(check); reject(signal.reason ?? new DOMException('aborted', 'AbortError')); };
|
|
278
|
+
const timer = setTimeout(finish, seconds * 1000);
|
|
279
|
+
this.waiters.add(check);
|
|
280
|
+
if (signal?.aborted)
|
|
281
|
+
onAbort();
|
|
282
|
+
else
|
|
283
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/** The SSE body after `after`, as `streamRunEvents` writes it. Validation happens before the caller calls this. */
|
|
287
|
+
openStream(after, signal) {
|
|
288
|
+
const encoder = new TextEncoder();
|
|
289
|
+
let position = after;
|
|
290
|
+
let closed = false;
|
|
291
|
+
let release = () => { };
|
|
292
|
+
let idleTimer;
|
|
293
|
+
let keepalive;
|
|
294
|
+
let controllerRef;
|
|
295
|
+
const write = (text) => { if (!closed) {
|
|
296
|
+
try {
|
|
297
|
+
controllerRef.enqueue(encoder.encode(text));
|
|
298
|
+
}
|
|
299
|
+
catch { /* reader gone */ }
|
|
300
|
+
} };
|
|
301
|
+
const stream = {
|
|
302
|
+
push: (event) => {
|
|
303
|
+
if (closed || event.sequence_number <= position)
|
|
304
|
+
return;
|
|
305
|
+
write(`id: ${event.sequence_number}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
306
|
+
position = event.sequence_number;
|
|
307
|
+
lastMove = Date.now();
|
|
308
|
+
},
|
|
309
|
+
idle: () => { write('event: idle\ndata: {}\n\n'); stream.end(); },
|
|
310
|
+
error: (error) => { write(`event: error\ndata: ${JSON.stringify(envelopeBody(error, this.id))}\n\n`); stream.end(); },
|
|
311
|
+
end: () => {
|
|
312
|
+
if (closed)
|
|
313
|
+
return;
|
|
314
|
+
closed = true;
|
|
315
|
+
this.streams.delete(stream);
|
|
316
|
+
if (idleTimer)
|
|
317
|
+
clearInterval(idleTimer);
|
|
318
|
+
if (keepalive)
|
|
319
|
+
clearInterval(keepalive);
|
|
320
|
+
signal?.removeEventListener('abort', stream.end);
|
|
321
|
+
release(position);
|
|
322
|
+
try {
|
|
323
|
+
controllerRef.close();
|
|
324
|
+
}
|
|
325
|
+
catch { /* already closed */ }
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
let lastMove = Date.now();
|
|
329
|
+
return new ReadableStream({
|
|
330
|
+
start: (controller) => {
|
|
331
|
+
controllerRef = controller;
|
|
332
|
+
write(': connected\n\n');
|
|
333
|
+
this.streams.add(stream);
|
|
334
|
+
release = this.host.streamOpened(this);
|
|
335
|
+
// A reader this far behind is dropped rather than replayed (`readRunEvents`/`streamRunEvents`).
|
|
336
|
+
const backlog = this.log.filter((event) => event.sequence_number > after);
|
|
337
|
+
if (backlog.length > BACKLOG_MAX_EVENTS || backlog.reduce((bytes, event) => bytes + encoder.encode(JSON.stringify(event)).byteLength, 0) > BACKLOG_MAX_BYTES) {
|
|
338
|
+
stream.error(fakeError({ status: 503, code: 'stream_dropped', message: 'stream reader fell behind', details: { last_sequence: after }, retryable: true }));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
for (const event of backlog)
|
|
342
|
+
stream.push(event);
|
|
343
|
+
if (closed)
|
|
344
|
+
return;
|
|
345
|
+
if (!this.host.grantActive(this.userId)) {
|
|
346
|
+
stream.error(fakeError({ status: 401, code: 'grant_revoked', message: 'grant is not active' }));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const idleMs = this.host.streamIdleMs;
|
|
350
|
+
idleTimer = setInterval(() => {
|
|
351
|
+
if (closed)
|
|
352
|
+
return;
|
|
353
|
+
if (this.status === 'running') {
|
|
354
|
+
lastMove = Date.now();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (Date.now() - lastMove >= idleMs)
|
|
358
|
+
stream.idle();
|
|
359
|
+
}, Math.max(5, Math.min(1000, Math.floor(idleMs / 4))));
|
|
360
|
+
idleTimer.unref?.();
|
|
361
|
+
keepalive = setInterval(() => write(': keepalive\n\n'), KEEPALIVE_MS);
|
|
362
|
+
keepalive.unref?.();
|
|
363
|
+
if (signal?.aborted)
|
|
364
|
+
stream.end();
|
|
365
|
+
else
|
|
366
|
+
signal?.addEventListener('abort', stream.end, { once: true });
|
|
367
|
+
},
|
|
368
|
+
cancel: () => stream.end(),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
/** The grant went inactive: every open stream gets the 401 `grant_revoked` error frame. */
|
|
372
|
+
revokeStreams() {
|
|
373
|
+
for (const stream of [...this.streams])
|
|
374
|
+
stream.error(fakeError({ status: 401, code: 'grant_revoked', message: 'grant is not active' }));
|
|
375
|
+
}
|
|
376
|
+
get openStreams() { return this.streams.size; }
|
|
377
|
+
/** Notify held reads that state changed. */
|
|
378
|
+
wake() { for (const waiter of [...this.waiters])
|
|
379
|
+
waiter(); }
|
|
380
|
+
// ---- internals -------------------------------------------------------------------------------------------------
|
|
381
|
+
assertLive() { if (this.deleted)
|
|
382
|
+
throw new Error(`run ${this.id} was deleted`); }
|
|
383
|
+
turnOpen() {
|
|
384
|
+
const turn = this.log.filter((e) => e.node_id === this.id && (e.type === 'node.turn.started' || e.type === 'node.turn.completed')).at(-1);
|
|
385
|
+
return turn?.type === 'node.turn.started';
|
|
386
|
+
}
|
|
387
|
+
everStartedTurn() { return this.turnsStarted > 0; }
|
|
388
|
+
turnsStarted = 0;
|
|
389
|
+
turn(messageIds, body) {
|
|
390
|
+
this.assertLive();
|
|
391
|
+
if (this.outcomeValue)
|
|
392
|
+
throw new Error(`run ${this.id} is settled; message it first to reopen`);
|
|
393
|
+
const delivering = messageIds === undefined ? this.pending : this.messages.filter((m) => messageIds.includes(m.message_id));
|
|
394
|
+
const ids = messageIds ?? delivering.map((m) => m.message_id);
|
|
395
|
+
const text = delivering.length ? delivering.map((m) => m.text).join('\n\n') : this.turnsStarted === 0 ? this.params.prompt : '';
|
|
396
|
+
this.turnsStarted++;
|
|
397
|
+
this.append('node.turn.started', { input: { text, message_ids: ids }, entry_id: entryId() });
|
|
398
|
+
for (const message of delivering)
|
|
399
|
+
message.delivered = true;
|
|
400
|
+
const ending = body(ids);
|
|
401
|
+
this.rested = true;
|
|
402
|
+
this.answered = false;
|
|
403
|
+
// The status the run reads once this turn is closed (`run_status` is written as the turn ends).
|
|
404
|
+
const status = this.messages.some((m) => m.start_turn && !m.delivered) ? 'running' : this.pendingQuestionIds.length ? 'waiting_on_user' : 'idle';
|
|
405
|
+
this.append('node.turn.completed', {
|
|
406
|
+
message_ids: ending.ids, run_status: status, stop_reason: ending.stop_reason,
|
|
407
|
+
...(ending.error ? { error: ending.error } : {}), entry_id: entryId(),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
append(type, fields, nodeId) {
|
|
411
|
+
const event = { ...fields, type, run_id: this.id, node_id: nodeId ?? this.id, sequence_number: ++this.sequence, ts: nowIso(), metadata: this.metadata };
|
|
412
|
+
this.log.push(event);
|
|
413
|
+
this.override = undefined;
|
|
414
|
+
this.lastActivityAt = event.ts;
|
|
415
|
+
for (const stream of [...this.streams])
|
|
416
|
+
stream.push(event);
|
|
417
|
+
this.host.appended(this);
|
|
418
|
+
this.wake();
|
|
419
|
+
return event;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function split(text, chunks) {
|
|
423
|
+
const count = Math.max(1, Math.min(chunks, Math.max(1, text.length)));
|
|
424
|
+
const size = Math.ceil(text.length / count);
|
|
425
|
+
const pieces = [];
|
|
426
|
+
for (let i = 0; i < count; i++)
|
|
427
|
+
pieces.push(text.slice(i * size, (i + 1) * size));
|
|
428
|
+
return pieces.filter((piece, i) => piece !== '' || i === 0);
|
|
429
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { GrantGetDTO, ProviderDependency, SetupState } from '@north-light/api';
|
|
2
|
+
import type { FakeRun } from './run.js';
|
|
3
|
+
/** The directory's reasons for refusing a refresh token (the SDK's refusal table). */
|
|
4
|
+
export type FakeRefusalCode = 'grant_removed' | 'grant_suspended' | 'refresh_token_expired' | 'refresh_token_reused' | 'grant_revoked';
|
|
5
|
+
/** What a run's responder receives. */
|
|
6
|
+
export interface ResponderInput {
|
|
7
|
+
run: FakeRun;
|
|
8
|
+
/** `start` after `run.start`; `message` after a `run.message` with `start_turn` true. */
|
|
9
|
+
kind: 'start' | 'message';
|
|
10
|
+
/** The messages that request accepted (a start without `message` has none). */
|
|
11
|
+
messages: FakeRun['messages'][number][];
|
|
12
|
+
}
|
|
13
|
+
/** Called after the fake answered a `run.start`, or a `run.message` with `start_turn` true; never for a replay. */
|
|
14
|
+
export type Responder = (input: ResponderInput) => void | Promise<void>;
|
|
15
|
+
/** A scope the grant holds: a string is `mode: 'always'`. */
|
|
16
|
+
export type FakeScope = string | {
|
|
17
|
+
scope: string;
|
|
18
|
+
mode: 'always' | 'ask';
|
|
19
|
+
};
|
|
20
|
+
/** A document in the person's memory. */
|
|
21
|
+
export interface FakeMemoryDoc {
|
|
22
|
+
name: string;
|
|
23
|
+
body: string;
|
|
24
|
+
privacy?: 'public' | 'friends' | 'private';
|
|
25
|
+
kind?: 'knowledge' | 'preference';
|
|
26
|
+
created_at?: string;
|
|
27
|
+
updated_at?: string;
|
|
28
|
+
summary?: string | null;
|
|
29
|
+
preview?: string;
|
|
30
|
+
revision?: number;
|
|
31
|
+
}
|
|
32
|
+
export type StoredMemoryDoc = Required<Omit<FakeMemoryDoc, 'summary'>> & {
|
|
33
|
+
summary: string | null;
|
|
34
|
+
};
|
|
35
|
+
interface Hold {
|
|
36
|
+
wake(): void;
|
|
37
|
+
}
|
|
38
|
+
/** One entry of the per-user idempotency map (the daemon's `idempotency_keys` row). */
|
|
39
|
+
export interface IdempotencyEntry {
|
|
40
|
+
hash: string;
|
|
41
|
+
runId: string;
|
|
42
|
+
status: number;
|
|
43
|
+
body: unknown;
|
|
44
|
+
}
|
|
45
|
+
export declare const normalizeScopes: (scopes: readonly FakeScope[]) => GrantGetDTO["resolved_scopes"];
|
|
46
|
+
/** Internal per-user state. */
|
|
47
|
+
export declare class UserState {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly clientId: string;
|
|
50
|
+
private readonly onRevoke;
|
|
51
|
+
active: boolean;
|
|
52
|
+
rev: number;
|
|
53
|
+
resolvedScopes: GrantGetDTO['resolved_scopes'];
|
|
54
|
+
spendCap: number | null;
|
|
55
|
+
dependencies: Record<string, ProviderDependency>;
|
|
56
|
+
/** The directory's answer to this user's refreshes: null is `ok`. */
|
|
57
|
+
refusal: FakeRefusalCode | null;
|
|
58
|
+
/** The valid refresh token, or null before the first connect. */
|
|
59
|
+
refreshToken: string | null;
|
|
60
|
+
/** Access tokens with a serial below this answer 401 `unauthorized`. */
|
|
61
|
+
minSerial: number;
|
|
62
|
+
readonly grantId: string;
|
|
63
|
+
readonly runs: FakeRun[];
|
|
64
|
+
responder: Responder | null;
|
|
65
|
+
readonly memory: Map<string, StoredMemoryDoc>;
|
|
66
|
+
readonly idempotency: Map<string, IdempotencyEntry>;
|
|
67
|
+
readonly holds: Set<Hold>;
|
|
68
|
+
constructor(id: string, clientId: string, onRevoke: (user: UserState) => void);
|
|
69
|
+
dto(exp?: number): GrantGetDTO | null;
|
|
70
|
+
version(): string | null;
|
|
71
|
+
/** The scopes the runtime authorizes with: only `always` ones (`activeRecord`, core/grants/mirror.ts). An `ask`
|
|
72
|
+
* scope is listed on the grant but grants nothing until the person approves it. */
|
|
73
|
+
scopeStrings(): string[];
|
|
74
|
+
wakeHolds(): void;
|
|
75
|
+
apply(patch: GrantPatch): void;
|
|
76
|
+
revoke(code: FakeRefusalCode): void;
|
|
77
|
+
putMemory(doc: FakeMemoryDoc): void;
|
|
78
|
+
}
|
|
79
|
+
/** `grant.set` / `grant.restore` patch. */
|
|
80
|
+
export interface GrantPatch {
|
|
81
|
+
dependencies?: Record<string, ProviderDependency>;
|
|
82
|
+
resolved_scopes?: FakeScope[];
|
|
83
|
+
spend_cap?: number | null;
|
|
84
|
+
}
|
|
85
|
+
/** The test's handle on one person. */
|
|
86
|
+
export interface FakeUser {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly grant: {
|
|
89
|
+
/** The current `GrantGetDTO`, or null when the grant is inactive. */
|
|
90
|
+
readonly state: GrantGetDTO | null;
|
|
91
|
+
/** Merge any of `dependencies`, `resolved_scopes`, `spend_cap`; `rev` +1. Wakes held reads. */
|
|
92
|
+
set(patch: GrantPatch): void;
|
|
93
|
+
/** Set one provider dependency without a `rev` bump; `version` changes. Wakes held reads. */
|
|
94
|
+
setup(provider: string, state: SetupState, options?: {
|
|
95
|
+
message?: string;
|
|
96
|
+
next?: string;
|
|
97
|
+
}): void;
|
|
98
|
+
/** A grant sync with unchanged content: nothing changes, no held read wakes. */
|
|
99
|
+
sync(): void;
|
|
100
|
+
/** The grant goes inactive; the directory refuses refreshes with `code`. */
|
|
101
|
+
revoke(code?: FakeRefusalCode): void;
|
|
102
|
+
/** Active again at `rev` +1; the directory answers `ok`. */
|
|
103
|
+
restore(patch?: GrantPatch): void;
|
|
104
|
+
};
|
|
105
|
+
/** The directory refuses each refresh with `code` (`'ok'` clears it); tokens issued before now answer 401 `unauthorized`. */
|
|
106
|
+
refuse(code: FakeRefusalCode | 'ok'): void;
|
|
107
|
+
/** Tokens issued before now answer 401 `unauthorized`, forcing a refresh; the directory's answer is unchanged. */
|
|
108
|
+
expireTokens(): void;
|
|
109
|
+
/** This user's `grant.get` calls. */
|
|
110
|
+
readonly reads: import('./cloud.js').FakeCall[];
|
|
111
|
+
/** `grant.get` reads held right now. */
|
|
112
|
+
readonly holding: number;
|
|
113
|
+
/** This user's runs (`runs.all`, in start order); `runs.respond(fn)` overrides the cloud's responder for this user
|
|
114
|
+
* (null clears it). The same shape as `cloud.runs`. */
|
|
115
|
+
readonly runs: import('./cloud.js').FakeRuns;
|
|
116
|
+
readonly memory: {
|
|
117
|
+
put(doc: FakeMemoryDoc): void;
|
|
118
|
+
remove(name: string): void;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { grantVersion, nowIso } from './wire.js';
|
|
2
|
+
export const normalizeScopes = (scopes) => scopes.map((scope) => (typeof scope === 'string' ? { scope, mode: 'always' } : { scope: scope.scope, mode: scope.mode }));
|
|
3
|
+
/** Internal per-user state. */
|
|
4
|
+
export class UserState {
|
|
5
|
+
id;
|
|
6
|
+
clientId;
|
|
7
|
+
onRevoke;
|
|
8
|
+
active = false;
|
|
9
|
+
rev = 0;
|
|
10
|
+
resolvedScopes = [];
|
|
11
|
+
spendCap = null;
|
|
12
|
+
dependencies = {};
|
|
13
|
+
/** The directory's answer to this user's refreshes: null is `ok`. */
|
|
14
|
+
refusal = null;
|
|
15
|
+
/** The valid refresh token, or null before the first connect. */
|
|
16
|
+
refreshToken = null;
|
|
17
|
+
/** Access tokens with a serial below this answer 401 `unauthorized`. */
|
|
18
|
+
minSerial = 0;
|
|
19
|
+
grantId;
|
|
20
|
+
runs = [];
|
|
21
|
+
responder = null;
|
|
22
|
+
memory = new Map();
|
|
23
|
+
idempotency = new Map();
|
|
24
|
+
holds = new Set();
|
|
25
|
+
constructor(id, clientId, onRevoke) {
|
|
26
|
+
this.id = id;
|
|
27
|
+
this.clientId = clientId;
|
|
28
|
+
this.onRevoke = onRevoke;
|
|
29
|
+
this.grantId = `grant-${id.replace(/^user:/, '')}`;
|
|
30
|
+
}
|
|
31
|
+
dto(exp = Math.floor(Date.now() / 1000) + 3600) {
|
|
32
|
+
if (!this.active)
|
|
33
|
+
return null;
|
|
34
|
+
const body = {
|
|
35
|
+
sub: this.id, grantee: `app:${this.clientId}`, client_id: this.clientId, grant: this.grantId, exp, rev: this.rev,
|
|
36
|
+
resolved_scopes: this.resolvedScopes, spend_cap: this.spendCap, payer: 'user', dependencies: this.dependencies,
|
|
37
|
+
};
|
|
38
|
+
return { ...body, version: grantVersion(body) };
|
|
39
|
+
}
|
|
40
|
+
version() { return this.active ? this.dto().version : null; }
|
|
41
|
+
/** The scopes the runtime authorizes with: only `always` ones (`activeRecord`, core/grants/mirror.ts). An `ask`
|
|
42
|
+
* scope is listed on the grant but grants nothing until the person approves it. */
|
|
43
|
+
scopeStrings() { return this.resolvedScopes.filter((entry) => entry.mode === 'always').map((entry) => entry.scope); }
|
|
44
|
+
wakeHolds() { for (const hold of [...this.holds])
|
|
45
|
+
hold.wake(); }
|
|
46
|
+
apply(patch) {
|
|
47
|
+
if (patch.resolved_scopes !== undefined)
|
|
48
|
+
this.resolvedScopes = normalizeScopes(patch.resolved_scopes);
|
|
49
|
+
if (patch.spend_cap !== undefined)
|
|
50
|
+
this.spendCap = patch.spend_cap;
|
|
51
|
+
if (patch.dependencies !== undefined)
|
|
52
|
+
this.dependencies = { ...patch.dependencies };
|
|
53
|
+
}
|
|
54
|
+
revoke(code) {
|
|
55
|
+
this.active = false;
|
|
56
|
+
this.refusal = code;
|
|
57
|
+
this.wakeHolds();
|
|
58
|
+
this.onRevoke(this);
|
|
59
|
+
}
|
|
60
|
+
putMemory(doc) {
|
|
61
|
+
const existing = this.memory.get(doc.name);
|
|
62
|
+
const now = nowIso();
|
|
63
|
+
this.memory.set(doc.name, {
|
|
64
|
+
name: doc.name, body: doc.body, privacy: doc.privacy ?? 'public', kind: doc.kind ?? 'knowledge',
|
|
65
|
+
created_at: doc.created_at ?? existing?.created_at ?? now, updated_at: doc.updated_at ?? now,
|
|
66
|
+
summary: doc.summary ?? null, preview: doc.preview ?? doc.body.slice(0, 200),
|
|
67
|
+
revision: doc.revision ?? (existing ? existing.revision + 1 : 1),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|