@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.
Files changed (40) hide show
  1. package/dist/client.d.ts +14 -0
  2. package/dist/client.js +72 -33
  3. package/dist/crtr.d.ts +24 -0
  4. package/dist/crtr.js +50 -0
  5. package/dist/env-command.d.ts +15 -0
  6. package/dist/env-command.js +138 -0
  7. package/dist/error-codes.d.ts +1 -1
  8. package/dist/error-codes.js +2 -0
  9. package/dist/errors.d.ts +15 -0
  10. package/dist/errors.js +38 -24
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.js +2 -2
  13. package/dist/keygen-cli.js +6 -1
  14. package/dist/keygen-command.js +15 -41
  15. package/dist/oauth/index.d.ts +19 -1
  16. package/dist/oauth/index.js +111 -25
  17. package/dist/resources/request.d.ts +5 -0
  18. package/dist/resources/request.js +31 -0
  19. package/dist/resources/runs.d.ts +7 -0
  20. package/dist/resources/runs.js +37 -17
  21. package/dist/testing/clock.d.ts +7 -0
  22. package/dist/testing/clock.js +12 -0
  23. package/dist/testing/cloud.d.ts +19 -0
  24. package/dist/testing/cloud.js +48 -14
  25. package/dist/testing/conformance/index.d.ts +2 -0
  26. package/dist/testing/conformance/index.js +3 -0
  27. package/dist/testing/conformance/keyed-write.d.ts +43 -0
  28. package/dist/testing/conformance/keyed-write.js +124 -0
  29. package/dist/testing/index.d.ts +2 -0
  30. package/dist/testing/index.js +1 -0
  31. package/dist/testing/notices.d.ts +7 -1
  32. package/dist/testing/notices.js +7 -3
  33. package/dist/testing/run.d.ts +2 -0
  34. package/dist/testing/run.js +5 -5
  35. package/dist/testing/users.d.ts +9 -3
  36. package/dist/testing/users.js +11 -3
  37. package/dist/types.d.ts +8 -0
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +7 -3
@@ -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;
@@ -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 owner = op === 'directory.token' ? [...this.users.values()].find((user) => user.refreshToken === presented)?.id ?? this.superseded.get(presented) ?? null : null;
347
- const call = { op, userId: owner, answer: 'ok', at: Date.now() };
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(Date.now() / 1000);
393
- return unsignedJwt({ sub: user.id, grant: user.grantId, exp: now + 3600, iat: now, rev: user.rev, serial: ++this.tokenSerial });
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: Date.now(),
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.idempotency.get(key);
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.idempotency.get(key);
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,2 @@
1
+ export { IDEMPOTENCY_WINDOW_MS, keyedWriteScenarios } from './keyed-write.js';
2
+ export type { AcceptedMessage, KeyedWriteCaller, KeyedWriteFailure, KeyedWriteScenario, KeyedWriteTarget } from './keyed-write.js';
@@ -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
+ ];
@@ -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';
@@ -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;
@@ -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: Date.now() };
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(Date.now() / 1000);
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,
@@ -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). */
@@ -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;
@@ -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;
@@ -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(Date.now() / 1000) + 3600) {
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.414";
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.414';
3
+ export const SDK_VERSION = '0.3.416';