@north-light/sdk 0.3.414 → 0.3.416
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/dist/client.d.ts +14 -0
- package/dist/client.js +72 -33
- 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 +1 -1
- package/dist/error-codes.js +2 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +38 -24
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/keygen-cli.js +6 -1
- package/dist/keygen-command.js +15 -41
- package/dist/oauth/index.d.ts +19 -1
- package/dist/oauth/index.js +111 -25
- package/dist/resources/request.d.ts +5 -0
- package/dist/resources/request.js +31 -0
- package/dist/resources/runs.d.ts +7 -0
- package/dist/resources/runs.js +37 -17
- package/dist/testing/clock.d.ts +7 -0
- package/dist/testing/clock.js +12 -0
- package/dist/testing/cloud.d.ts +19 -0
- package/dist/testing/cloud.js +48 -14
- 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 +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/notices.d.ts +7 -1
- package/dist/testing/notices.js +7 -3
- package/dist/testing/run.d.ts +2 -0
- package/dist/testing/run.js +5 -5
- package/dist/testing/users.d.ts +9 -3
- package/dist/testing/users.js +11 -3
- package/dist/types.d.ts +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -3
package/dist/testing/cloud.d.ts
CHANGED
|
@@ -90,6 +90,13 @@ export interface FakeCloudOptions {
|
|
|
90
90
|
* runtime without the launcher (a developer's Mac): every isolated start is 503 `isolated_unavailable`, retryable.
|
|
91
91
|
*/
|
|
92
92
|
isolatedRuns?: 'available' | 'unavailable';
|
|
93
|
+
/**
|
|
94
|
+
* The clock every timestamp the fake writes reads, and the idempotency window runs on (default `Date.now`). Use
|
|
95
|
+
* `manualClock()` to step time. Stream idle, long-poll waits and run-notice retries stay on real timers.
|
|
96
|
+
*/
|
|
97
|
+
clock?: {
|
|
98
|
+
now(): number;
|
|
99
|
+
};
|
|
93
100
|
}
|
|
94
101
|
/** `cloud.runs` and `user.runs`: the runs in start order, and the responder for them. */
|
|
95
102
|
export interface FakeRuns {
|
|
@@ -117,6 +124,7 @@ export declare class FakeCloud {
|
|
|
117
124
|
define(provider: string, definition: FakeProvider): void;
|
|
118
125
|
};
|
|
119
126
|
private readonly options;
|
|
127
|
+
private readonly clock;
|
|
120
128
|
private readonly users;
|
|
121
129
|
private readonly handles;
|
|
122
130
|
private readonly allRuns;
|
|
@@ -155,6 +163,15 @@ export declare class FakeCloud {
|
|
|
155
163
|
scopes?: FakeScope[];
|
|
156
164
|
dependencies?: Record<string, ProviderDependency>;
|
|
157
165
|
}): Promise<FakeUser>;
|
|
166
|
+
private activateState;
|
|
167
|
+
/**
|
|
168
|
+
* Make `userId`'s grant active with no stored connection, for an app that mints a per-user runtime token through
|
|
169
|
+
* the directory's `client_credentials` grant. Default scopes: `crtr:llm`, `crtr:act`.
|
|
170
|
+
*/
|
|
171
|
+
activate(userId: string, options?: {
|
|
172
|
+
scopes?: FakeScope[];
|
|
173
|
+
dependencies?: Record<string, ProviderDependency>;
|
|
174
|
+
}): FakeUser;
|
|
158
175
|
/**
|
|
159
176
|
* A real `Crouter` on `userId`'s runtime with a fresh access token and no refresh: refusals never reach it. For
|
|
160
177
|
* code whose production signature takes a `Crouter`; an app that goes through `OAuth2Client` tests through it.
|
|
@@ -186,6 +203,8 @@ export declare class FakeCloud {
|
|
|
186
203
|
private route;
|
|
187
204
|
private runIntercepts;
|
|
188
205
|
private directory;
|
|
206
|
+
/** `grant_type=client_credentials` + `sub`: the directory's per-user runtime-token mint. The client assertion is not verified. */
|
|
207
|
+
private mint;
|
|
189
208
|
private refresh;
|
|
190
209
|
private newRefreshToken;
|
|
191
210
|
private accessToken;
|
package/dist/testing/cloud.js
CHANGED
|
@@ -71,6 +71,7 @@ export class FakeCloud {
|
|
|
71
71
|
runs;
|
|
72
72
|
providers;
|
|
73
73
|
options;
|
|
74
|
+
clock;
|
|
74
75
|
users = new Map();
|
|
75
76
|
handles = new Map();
|
|
76
77
|
allRuns = [];
|
|
@@ -90,16 +91,18 @@ export class FakeCloud {
|
|
|
90
91
|
if (!options || typeof options.clientId !== 'string' || !options.clientId)
|
|
91
92
|
throw new TypeError('FakeCloud needs a clientId');
|
|
92
93
|
this.options = options;
|
|
94
|
+
this.clock = options.clock ?? { now: () => Date.now() };
|
|
93
95
|
this.clientId = options.clientId;
|
|
94
96
|
this.issuer = (options.issuer ?? 'https://directory.test').replace(/\/$/, '');
|
|
95
97
|
this.runNotices = new RunNotices(options.runNotice, this.key, {
|
|
96
98
|
issuer: this.issuer, clientId: this.clientId, runtimeId: options.runtimeId ?? 'runtime:test', runtimeClientId: options.runtimeClientId ?? 'runtime-client',
|
|
97
|
-
}, (userId) => this.users.get(userId)?.active === true);
|
|
99
|
+
}, (userId) => this.users.get(userId)?.active === true, this.clock);
|
|
98
100
|
this.notices = this.runNotices.records;
|
|
99
101
|
const grantee = `app:${this.clientId}`;
|
|
100
102
|
this.host = {
|
|
101
103
|
grantee,
|
|
102
104
|
streamIdleMs: options.streamIdleMs ?? 60_000,
|
|
105
|
+
now: () => this.clock.now(),
|
|
103
106
|
grantActive: (userId) => this.users.get(userId)?.active === true,
|
|
104
107
|
appended: (run) => this.runNotices.appended(run),
|
|
105
108
|
streamOpened: (run) => this.runNotices.streamOpened(run),
|
|
@@ -186,6 +189,12 @@ export class FakeCloud {
|
|
|
186
189
|
async connect(store, userId, options = {}) {
|
|
187
190
|
const handle = this.user(userId);
|
|
188
191
|
const state = this.state(userId);
|
|
192
|
+
this.activateState(state, options);
|
|
193
|
+
state.refreshToken ??= this.newRefreshToken(userId);
|
|
194
|
+
await store.save({ userId, runtimeUrl: this.runtimeUrlFor(userId), refreshToken: state.refreshToken, grantId: state.grantId, accessToken: '', accessTokenExpiresAt: 0 });
|
|
195
|
+
return handle;
|
|
196
|
+
}
|
|
197
|
+
activateState(state, options) {
|
|
189
198
|
if (!state.active) {
|
|
190
199
|
state.active = true;
|
|
191
200
|
state.refusal = null;
|
|
@@ -200,8 +209,16 @@ export class FakeCloud {
|
|
|
200
209
|
state.rev++;
|
|
201
210
|
state.wakeHolds();
|
|
202
211
|
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Make `userId`'s grant active with no stored connection, for an app that mints a per-user runtime token through
|
|
215
|
+
* the directory's `client_credentials` grant. Default scopes: `crtr:llm`, `crtr:act`.
|
|
216
|
+
*/
|
|
217
|
+
activate(userId, options = {}) {
|
|
218
|
+
const handle = this.user(userId);
|
|
219
|
+
const state = this.state(userId);
|
|
220
|
+
this.activateState(state, options);
|
|
203
221
|
state.refreshToken ??= this.newRefreshToken(userId);
|
|
204
|
-
await store.save({ userId, runtimeUrl: this.runtimeUrlFor(userId), refreshToken: state.refreshToken, grantId: state.grantId, accessToken: '', accessTokenExpiresAt: 0 });
|
|
205
222
|
return handle;
|
|
206
223
|
}
|
|
207
224
|
/**
|
|
@@ -283,7 +300,7 @@ export class FakeCloud {
|
|
|
283
300
|
if (!state) {
|
|
284
301
|
state = new UserState(userId, this.clientId, (user) => { for (const run of user.runs)
|
|
285
302
|
if (!run.deleted)
|
|
286
|
-
run.revokeStreams(); });
|
|
303
|
+
run.revokeStreams(); }, this.clock);
|
|
287
304
|
this.users.set(userId, state);
|
|
288
305
|
}
|
|
289
306
|
return state;
|
|
@@ -343,8 +360,10 @@ export class FakeCloud {
|
|
|
343
360
|
throw new Error(`FakeCloud: the fake directory does not serve ${request.method} ${url.href}`);
|
|
344
361
|
const params = op === 'directory.token' ? new URLSearchParams(await request.text()) : null;
|
|
345
362
|
const presented = params?.get('refresh_token') ?? '';
|
|
346
|
-
const
|
|
347
|
-
const
|
|
363
|
+
const credentials = params?.get('grant_type') === 'client_credentials';
|
|
364
|
+
const owner = op === 'directory.token' && credentials ? params.get('sub') || null
|
|
365
|
+
: op === 'directory.token' ? [...this.users.values()].find((user) => user.refreshToken === presented)?.id ?? this.superseded.get(presented) ?? null : null;
|
|
366
|
+
const call = { op, userId: owner, answer: 'ok', at: this.clock.now() };
|
|
348
367
|
this.directoryCalls.push(call);
|
|
349
368
|
const scripted = await this.runIntercepts(op, call, signal);
|
|
350
369
|
if (scripted !== undefined) {
|
|
@@ -365,7 +384,22 @@ export class FakeCloud {
|
|
|
365
384
|
}
|
|
366
385
|
if (op === 'directory.jwks')
|
|
367
386
|
return Response.json(await this.key.jwks());
|
|
368
|
-
return this.refresh(params, presented, call);
|
|
387
|
+
return credentials ? this.mint(params, call) : this.refresh(params, presented, call);
|
|
388
|
+
}
|
|
389
|
+
/** `grant_type=client_credentials` + `sub`: the directory's per-user runtime-token mint. The client assertion is not verified. */
|
|
390
|
+
mint(params, call) {
|
|
391
|
+
const sub = params.get('sub');
|
|
392
|
+
if (!sub) {
|
|
393
|
+
call.answer = 400;
|
|
394
|
+
return Response.json({ error: 'unauthorized_client' }, { status: 400 });
|
|
395
|
+
}
|
|
396
|
+
const user = this.users.get(sub);
|
|
397
|
+
const refused = (description, answer) => { call.answer = answer; return Response.json({ error: 'invalid_grant', error_description: description }, { status: 400 }); };
|
|
398
|
+
if (user?.refusal)
|
|
399
|
+
return refused(user.refusal, user.refusal);
|
|
400
|
+
if (!user?.active)
|
|
401
|
+
return refused('no active grant', 'grant_revoked');
|
|
402
|
+
return Response.json({ access_token: this.accessToken(user, 600), token_type: 'Bearer', expires_in: 600, runtimeUrl: this.runtimeUrlFor(user.id) });
|
|
369
403
|
}
|
|
370
404
|
refresh(params, presented, call) {
|
|
371
405
|
const refused = (code) => { call.answer = code; return Response.json({ error: 'invalid_grant', error_description: code }, { status: 400 }); };
|
|
@@ -388,9 +422,9 @@ export class FakeCloud {
|
|
|
388
422
|
});
|
|
389
423
|
}
|
|
390
424
|
newRefreshToken(userId) { return `rt-${++this.refreshSerial}-${userId}`; }
|
|
391
|
-
accessToken(user) {
|
|
392
|
-
const now = Math.floor(
|
|
393
|
-
return unsignedJwt({ sub: user.id, grant: user.grantId, exp: now +
|
|
425
|
+
accessToken(user, ttlSeconds = 3600) {
|
|
426
|
+
const now = Math.floor(this.clock.now() / 1000);
|
|
427
|
+
return unsignedJwt({ sub: user.id, grant: user.grantId, exp: now + ttlSeconds, iat: now, rev: user.rev, serial: ++this.tokenSerial });
|
|
394
428
|
}
|
|
395
429
|
// ---- runtime -----------------------------------------------------------------------------------------------------
|
|
396
430
|
async runtime(url, request, signal, claims, sub) {
|
|
@@ -419,7 +453,7 @@ export class FakeCloud {
|
|
|
419
453
|
const questionRun = questionId === null ? undefined : user?.runs.find((run) => !run.deleted && run.questions.has(questionId));
|
|
420
454
|
const call = {
|
|
421
455
|
userId: user ? user.id : null, op, runId: runId ?? questionRun?.id ?? null, questionId, method: request.method, path: url.pathname,
|
|
422
|
-
query: Object.fromEntries(url.searchParams), body, idempotencyKey: request.headers.get('idempotency-key'), status: null, at:
|
|
456
|
+
query: Object.fromEntries(url.searchParams), body, idempotencyKey: request.headers.get('idempotency-key'), status: null, at: this.clock.now(),
|
|
423
457
|
};
|
|
424
458
|
this.calls.push(call);
|
|
425
459
|
const answer = (status, payload) => { call.status = status; return jsonAnswer(payload, status, this.headers()); };
|
|
@@ -685,7 +719,7 @@ export class FakeCloud {
|
|
|
685
719
|
...(outputSchema === undefined ? {} : { output_schema: outputSchema }), ...(message === undefined ? {} : { message }),
|
|
686
720
|
}));
|
|
687
721
|
if (key !== null) {
|
|
688
|
-
const entry = user.
|
|
722
|
+
const entry = user.idempotent(key);
|
|
689
723
|
if (entry) {
|
|
690
724
|
if (entry.hash !== hash)
|
|
691
725
|
throw refuse(409, 'idempotency_conflict', 'key used with a different request');
|
|
@@ -713,7 +747,7 @@ export class FakeCloud {
|
|
|
713
747
|
...(accepted ? { message: { run_id: run.id, message_id: accepted.message_id, sequence_number: accepted.sequence_number } } : {}),
|
|
714
748
|
};
|
|
715
749
|
if (key !== null)
|
|
716
|
-
user.idempotency.set(key, { hash, runId: run.id, status: 201, body: structuredClone(response) });
|
|
750
|
+
user.idempotency.set(key, { hash, runId: run.id, status: 201, body: structuredClone(response), createdAt: this.clock.now() });
|
|
717
751
|
this.respond(run, 'start', accepted ? [accepted] : []);
|
|
718
752
|
return { status: 201, body: response };
|
|
719
753
|
}
|
|
@@ -730,7 +764,7 @@ export class FakeCloud {
|
|
|
730
764
|
const key = this.idempotencyKey(rawKey);
|
|
731
765
|
const hash = sha256Hex(JSON.stringify(['run.message', run.id, text, startTurn]));
|
|
732
766
|
if (key !== null) {
|
|
733
|
-
const entry = user.
|
|
767
|
+
const entry = user.idempotent(key);
|
|
734
768
|
if (entry) {
|
|
735
769
|
if (entry.hash !== hash)
|
|
736
770
|
throw refuse(409, 'idempotency_conflict', 'key used with a different request');
|
|
@@ -745,7 +779,7 @@ export class FakeCloud {
|
|
|
745
779
|
const accepted = run.accept(text, startTurn, key);
|
|
746
780
|
const response = { run_id: run.id, message_id: accepted.message_id, sequence_number: accepted.sequence_number };
|
|
747
781
|
if (key !== null)
|
|
748
|
-
user.idempotency.set(key, { hash, runId: run.id, status: 202, body: structuredClone(response) });
|
|
782
|
+
user.idempotency.set(key, { hash, runId: run.id, status: 202, body: structuredClone(response), createdAt: this.clock.now() });
|
|
749
783
|
if (startTurn)
|
|
750
784
|
this.respond(run, 'message', [accepted]);
|
|
751
785
|
return { status: 202, body: response };
|
|
@@ -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
|
+
];
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { FakeCloud } from './cloud.js';
|
|
2
|
+
export { manualClock } from './clock.js';
|
|
3
|
+
export type { ManualClock } from './clock.js';
|
|
2
4
|
export type { FakeAnswer, FakeCall, FakeCloudOptions, FakeDirectoryCall, FakeDirectoryOp, FakeInterceptOp, FakeOp, FakeProvider, FakeRuns, FakeToolHandler, Intercept, } from './cloud.js';
|
|
3
5
|
export type { FakeRun, FakeRunEnding, FakeRunMessage, FakeRunParams, FakeQuestion } from './run.js';
|
|
4
6
|
export type { FakeNotice, RunNoticeOptions } from './notices.js';
|
package/dist/testing/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
// `@north-light/sdk/testing`: a fake crouter cloud (directory + runtime) your real `OAuth2Client` and `Crouter` talk to
|
|
2
2
|
// through `fetch`. Node-only; no test framework. See docs/sdk/testing.md.
|
|
3
3
|
export { FakeCloud } from './cloud.js';
|
|
4
|
+
export { manualClock } from './clock.js';
|
|
@@ -46,6 +46,8 @@ export declare class RunNotices {
|
|
|
46
46
|
private readonly identity;
|
|
47
47
|
/** Whether the run's person holds an active grant: a revoked grant has no notice target (`runNoticeTarget`). */
|
|
48
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;
|
|
49
51
|
private readonly states;
|
|
50
52
|
readonly records: FakeNotice[];
|
|
51
53
|
constructor(options: RunNoticeOptions | undefined, key: SigningKey, identity: {
|
|
@@ -55,7 +57,11 @@ export declare class RunNotices {
|
|
|
55
57
|
runtimeClientId: string;
|
|
56
58
|
},
|
|
57
59
|
/** Whether the run's person holds an active grant: a revoked grant has no notice target (`runNoticeTarget`). */
|
|
58
|
-
grantActive: (userId: string) => boolean
|
|
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
|
+
});
|
|
59
65
|
private state;
|
|
60
66
|
/** An open stream satisfies anything owed; closing it with the log ahead of what it wrote owes a notice. */
|
|
61
67
|
streamOpened(run: FakeRun): (lastWritten: number) => void;
|
package/dist/testing/notices.js
CHANGED
|
@@ -36,15 +36,19 @@ export class RunNotices {
|
|
|
36
36
|
key;
|
|
37
37
|
identity;
|
|
38
38
|
grantActive;
|
|
39
|
+
clock;
|
|
39
40
|
states = new Map();
|
|
40
41
|
records = [];
|
|
41
42
|
constructor(options, key, identity,
|
|
42
43
|
/** Whether the run's person holds an active grant: a revoked grant has no notice target (`runNoticeTarget`). */
|
|
43
|
-
grantActive
|
|
44
|
+
grantActive,
|
|
45
|
+
/** Token claims and records read this clock; the outbox's gaps and retries stay on real timers. */
|
|
46
|
+
clock) {
|
|
44
47
|
this.options = options;
|
|
45
48
|
this.key = key;
|
|
46
49
|
this.identity = identity;
|
|
47
50
|
this.grantActive = grantActive;
|
|
51
|
+
this.clock = clock;
|
|
48
52
|
}
|
|
49
53
|
state(run) {
|
|
50
54
|
let state = this.states.get(run);
|
|
@@ -158,7 +162,7 @@ export class RunNotices {
|
|
|
158
162
|
const options = this.options;
|
|
159
163
|
for (let remint = false;; remint = true) {
|
|
160
164
|
const token = await this.mint(run);
|
|
161
|
-
const record = { runId: run.id, userId: run.userId, attempt, remint, latestSequence: latest, claims: decodeJwt(token), status: null, error: null, at:
|
|
165
|
+
const record = { runId: run.id, userId: run.userId, attempt, remint, latestSequence: latest, claims: decodeJwt(token), status: null, error: null, at: this.clock.now() };
|
|
162
166
|
this.records.push(record);
|
|
163
167
|
let response;
|
|
164
168
|
// Each request is bounded as the daemon's (`RUN_NOTICE_ATTEMPT_TIMEOUT_MS`): a hung handler is a retryable failure.
|
|
@@ -194,7 +198,7 @@ export class RunNotices {
|
|
|
194
198
|
}
|
|
195
199
|
}
|
|
196
200
|
mint(run) {
|
|
197
|
-
const now = Math.floor(
|
|
201
|
+
const now = Math.floor(this.clock.now() / 1000);
|
|
198
202
|
const app = `app:${this.identity.clientId}`;
|
|
199
203
|
return this.key.sign({
|
|
200
204
|
iss: this.identity.issuer, aud: this.options.url, sub: run.userId, grantee: app, client_id: this.identity.runtimeClientId,
|
package/dist/testing/run.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { DeclinedResultDTO, RunActivityDTO, RunEvent, RunEventFields, RunEv
|
|
|
3
3
|
export interface RunHost {
|
|
4
4
|
readonly grantee: string;
|
|
5
5
|
readonly streamIdleMs: number;
|
|
6
|
+
/** The cloud's clock, in ms: every timestamp the run writes reads it. */
|
|
7
|
+
now(): number;
|
|
6
8
|
/** Whether the run owner's grant is active now. */
|
|
7
9
|
grantActive(userId: string): boolean;
|
|
8
10
|
/** An event was appended (run-notice outbox). */
|
package/dist/testing/run.js
CHANGED
|
@@ -61,7 +61,7 @@ export class FakeRun {
|
|
|
61
61
|
this.params = params;
|
|
62
62
|
this.idempotencyKey = idempotencyKey;
|
|
63
63
|
this.name = id;
|
|
64
|
-
this.createdAt = nowIso();
|
|
64
|
+
this.createdAt = nowIso(this.host.now());
|
|
65
65
|
this.lastActivityAt = this.createdAt;
|
|
66
66
|
}
|
|
67
67
|
// ---- reads ---------------------------------------------------------------------------------------------------
|
|
@@ -150,7 +150,7 @@ export class FakeRun {
|
|
|
150
150
|
setActivity(activity, { at } = {}) {
|
|
151
151
|
this.assertLive();
|
|
152
152
|
this.override = activity;
|
|
153
|
-
this.lastActivityAt = at === undefined ? nowIso() : new Date(at).toISOString();
|
|
153
|
+
this.lastActivityAt = at === undefined ? nowIso(this.host.now()) : new Date(at).toISOString();
|
|
154
154
|
this.wake();
|
|
155
155
|
}
|
|
156
156
|
/** The run goes idle without a new turn (it stopped without submitting); undelivered start-turn mail is consumed. */
|
|
@@ -162,7 +162,7 @@ export class FakeRun {
|
|
|
162
162
|
for (const message of this.messages)
|
|
163
163
|
if (message.start_turn)
|
|
164
164
|
message.delivered = true;
|
|
165
|
-
this.lastActivityAt = nowIso();
|
|
165
|
+
this.lastActivityAt = nowIso(this.host.now());
|
|
166
166
|
this.wake();
|
|
167
167
|
}
|
|
168
168
|
/** Record the run's ending and emit `node.settled`. */
|
|
@@ -170,7 +170,7 @@ export class FakeRun {
|
|
|
170
170
|
this.assertLive();
|
|
171
171
|
if (this.outcomeValue)
|
|
172
172
|
throw new Error(`run ${this.id} is already settled`);
|
|
173
|
-
const settledAt = nowIso();
|
|
173
|
+
const settledAt = nowIso(this.host.now());
|
|
174
174
|
const base = { node_id: this.id, revision: ++this.outcomeRevision, settled_at: settledAt, structured_result: null, detail: null };
|
|
175
175
|
let outcome;
|
|
176
176
|
this.hasResult = false;
|
|
@@ -408,7 +408,7 @@ export class FakeRun {
|
|
|
408
408
|
});
|
|
409
409
|
}
|
|
410
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 };
|
|
411
|
+
const event = { ...fields, type, run_id: this.id, node_id: nodeId ?? this.id, sequence_number: ++this.sequence, ts: nowIso(this.host.now()), metadata: this.metadata };
|
|
412
412
|
this.log.push(event);
|
|
413
413
|
this.override = undefined;
|
|
414
414
|
this.lastActivityAt = event.ts;
|
package/dist/testing/users.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GrantGetDTO, ProviderDependency, SetupState } from '@north-light/api';
|
|
2
2
|
import type { FakeRun } from './run.js';
|
|
3
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';
|
|
4
|
+
export type FakeRefusalCode = 'grant_removed' | 'grant_suspended' | 'refresh_token_expired' | 'refresh_token_reused' | 'grant_revoked' | 'runtime_provisioning';
|
|
5
5
|
/** What a run's responder receives. */
|
|
6
6
|
export interface ResponderInput {
|
|
7
7
|
run: FakeRun;
|
|
@@ -40,7 +40,8 @@ export interface IdempotencyEntry {
|
|
|
40
40
|
hash: string;
|
|
41
41
|
runId: string;
|
|
42
42
|
status: number;
|
|
43
|
-
body: unknown;
|
|
43
|
+
body: unknown; /** ms on the cloud's clock. */
|
|
44
|
+
createdAt: number;
|
|
44
45
|
}
|
|
45
46
|
export declare const normalizeScopes: (scopes: readonly FakeScope[]) => GrantGetDTO["resolved_scopes"];
|
|
46
47
|
/** Internal per-user state. */
|
|
@@ -48,6 +49,7 @@ export declare class UserState {
|
|
|
48
49
|
readonly id: string;
|
|
49
50
|
readonly clientId: string;
|
|
50
51
|
private readonly onRevoke;
|
|
52
|
+
private readonly clock;
|
|
51
53
|
active: boolean;
|
|
52
54
|
rev: number;
|
|
53
55
|
resolvedScopes: GrantGetDTO['resolved_scopes'];
|
|
@@ -65,12 +67,16 @@ export declare class UserState {
|
|
|
65
67
|
readonly memory: Map<string, StoredMemoryDoc>;
|
|
66
68
|
readonly idempotency: Map<string, IdempotencyEntry>;
|
|
67
69
|
readonly holds: Set<Hold>;
|
|
68
|
-
constructor(id: string, clientId: string, onRevoke: (user: UserState) => void
|
|
70
|
+
constructor(id: string, clientId: string, onRevoke: (user: UserState) => void, clock: {
|
|
71
|
+
now(): number;
|
|
72
|
+
});
|
|
69
73
|
dto(exp?: number): GrantGetDTO | null;
|
|
70
74
|
version(): string | null;
|
|
71
75
|
/** The scopes the runtime authorizes with: only `always` ones (`activeRecord`, core/grants/mirror.ts). An `ask`
|
|
72
76
|
* scope is listed on the grant but grants nothing until the person approves it. */
|
|
73
77
|
scopeStrings(): string[];
|
|
78
|
+
/** The live record under `key`: one at least a window old reads as absent, as on the daemon. */
|
|
79
|
+
idempotent(key: string): IdempotencyEntry | undefined;
|
|
74
80
|
wakeHolds(): void;
|
|
75
81
|
apply(patch: GrantPatch): void;
|
|
76
82
|
revoke(code: FakeRefusalCode): void;
|
package/dist/testing/users.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { IDEMPOTENCY_WINDOW_MS } from './conformance/keyed-write.js';
|
|
1
2
|
import { grantVersion, nowIso } from './wire.js';
|
|
2
3
|
export const normalizeScopes = (scopes) => scopes.map((scope) => (typeof scope === 'string' ? { scope, mode: 'always' } : { scope: scope.scope, mode: scope.mode }));
|
|
3
4
|
/** Internal per-user state. */
|
|
@@ -5,6 +6,7 @@ export class UserState {
|
|
|
5
6
|
id;
|
|
6
7
|
clientId;
|
|
7
8
|
onRevoke;
|
|
9
|
+
clock;
|
|
8
10
|
active = false;
|
|
9
11
|
rev = 0;
|
|
10
12
|
resolvedScopes = [];
|
|
@@ -22,13 +24,14 @@ export class UserState {
|
|
|
22
24
|
memory = new Map();
|
|
23
25
|
idempotency = new Map();
|
|
24
26
|
holds = new Set();
|
|
25
|
-
constructor(id, clientId, onRevoke) {
|
|
27
|
+
constructor(id, clientId, onRevoke, clock) {
|
|
26
28
|
this.id = id;
|
|
27
29
|
this.clientId = clientId;
|
|
28
30
|
this.onRevoke = onRevoke;
|
|
31
|
+
this.clock = clock;
|
|
29
32
|
this.grantId = `grant-${id.replace(/^user:/, '')}`;
|
|
30
33
|
}
|
|
31
|
-
dto(exp = Math.floor(
|
|
34
|
+
dto(exp = Math.floor(this.clock.now() / 1000) + 3600) {
|
|
32
35
|
if (!this.active)
|
|
33
36
|
return null;
|
|
34
37
|
const body = {
|
|
@@ -41,6 +44,11 @@ export class UserState {
|
|
|
41
44
|
/** The scopes the runtime authorizes with: only `always` ones (`activeRecord`, core/grants/mirror.ts). An `ask`
|
|
42
45
|
* scope is listed on the grant but grants nothing until the person approves it. */
|
|
43
46
|
scopeStrings() { return this.resolvedScopes.filter((entry) => entry.mode === 'always').map((entry) => entry.scope); }
|
|
47
|
+
/** The live record under `key`: one at least a window old reads as absent, as on the daemon. */
|
|
48
|
+
idempotent(key) {
|
|
49
|
+
const entry = this.idempotency.get(key);
|
|
50
|
+
return entry && this.clock.now() - entry.createdAt < IDEMPOTENCY_WINDOW_MS ? entry : undefined;
|
|
51
|
+
}
|
|
44
52
|
wakeHolds() { for (const hold of [...this.holds])
|
|
45
53
|
hold.wake(); }
|
|
46
54
|
apply(patch) {
|
|
@@ -59,7 +67,7 @@ export class UserState {
|
|
|
59
67
|
}
|
|
60
68
|
putMemory(doc) {
|
|
61
69
|
const existing = this.memory.get(doc.name);
|
|
62
|
-
const now = nowIso();
|
|
70
|
+
const now = nowIso(this.clock.now());
|
|
63
71
|
this.memory.set(doc.name, {
|
|
64
72
|
name: doc.name, body: doc.body, privacy: doc.privacy ?? 'public', kind: doc.kind ?? 'knowledge',
|
|
65
73
|
created_at: doc.created_at ?? existing?.created_at ?? now, updated_at: doc.updated_at ?? now,
|
package/dist/types.d.ts
CHANGED
|
@@ -22,6 +22,14 @@ export interface RequestOptions {
|
|
|
22
22
|
timeout?: number;
|
|
23
23
|
/** Transient-failure retries for this request. Never applied to `POST` or `PATCH`. */
|
|
24
24
|
maxRetries?: number;
|
|
25
|
+
/** One overall deadline for this call, in milliseconds from now: it covers every attempt, every retry wait
|
|
26
|
+
* (including a server's `Retry-After`) and the response body. `timeout` still bounds each attempt but never
|
|
27
|
+
* past the deadline. When the deadline passes, or a retry wait would pass it, the call rejects with
|
|
28
|
+
* `DeadlineExceededError` at once instead of sleeping on. */
|
|
29
|
+
budgetMs?: number;
|
|
30
|
+
/** The same deadline as an absolute instant (epoch milliseconds), for passing one deadline to several calls.
|
|
31
|
+
* With `budgetMs` too, the earlier wins. */
|
|
32
|
+
deadlineAt?: number;
|
|
25
33
|
}
|
|
26
34
|
/** Options for one outcome poll. */
|
|
27
35
|
export interface NodeOutcomeOptions extends RequestOptions {
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This SDK's release version. It is also the oldest runtime the SDK accepts: one release is one version set. */
|
|
2
|
-
export declare const SDK_VERSION = "0.3.
|
|
2
|
+
export declare const SDK_VERSION = "0.3.416";
|
package/dist/version.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Written by scripts/release-version.mjs on every release; do not edit by hand.
|
|
2
2
|
/** This SDK's release version. It is also the oldest runtime the SDK accepts: one release is one version set. */
|
|
3
|
-
export const SDK_VERSION = '0.3.
|
|
3
|
+
export const SDK_VERSION = '0.3.416';
|