@north-light/sdk 0.0.0-stage → 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.
Files changed (111) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +187 -2
  3. package/dist/client.d.ts +162 -0
  4. package/dist/client.js +543 -0
  5. package/dist/error-codes.d.ts +7 -0
  6. package/dist/error-codes.js +41 -0
  7. package/dist/errors.d.ts +57 -0
  8. package/dist/errors.js +87 -0
  9. package/dist/index.d.ts +41 -0
  10. package/dist/index.js +17 -0
  11. package/dist/keygen-cli.d.ts +2 -0
  12. package/dist/keygen-cli.js +6 -0
  13. package/dist/keygen-command.d.ts +6 -0
  14. package/dist/keygen-command.js +157 -0
  15. package/dist/oauth/index.d.ts +217 -0
  16. package/dist/oauth/index.js +535 -0
  17. package/dist/oauth/keygen.d.ts +18 -0
  18. package/dist/oauth/keygen.js +46 -0
  19. package/dist/oauth/refusal.d.ts +32 -0
  20. package/dist/oauth/refusal.js +17 -0
  21. package/dist/resources/activity.d.ts +22 -0
  22. package/dist/resources/activity.js +43 -0
  23. package/dist/resources/attachments.d.ts +15 -0
  24. package/dist/resources/attachments.js +13 -0
  25. package/dist/resources/bash.d.ts +9 -0
  26. package/dist/resources/bash.js +15 -0
  27. package/dist/resources/canvas/history.d.ts +11 -0
  28. package/dist/resources/canvas/history.js +20 -0
  29. package/dist/resources/canvas.d.ts +19 -0
  30. package/dist/resources/canvas.js +42 -0
  31. package/dist/resources/crons.d.ts +15 -0
  32. package/dist/resources/crons.js +33 -0
  33. package/dist/resources/custom-objects.d.ts +20 -0
  34. package/dist/resources/custom-objects.js +84 -0
  35. package/dist/resources/files.d.ts +34 -0
  36. package/dist/resources/files.js +34 -0
  37. package/dist/resources/follower.d.ts +71 -0
  38. package/dist/resources/follower.js +202 -0
  39. package/dist/resources/forward.d.ts +7 -0
  40. package/dist/resources/forward.js +67 -0
  41. package/dist/resources/grant-watcher.d.ts +57 -0
  42. package/dist/resources/grant-watcher.js +100 -0
  43. package/dist/resources/human/inbox.d.ts +14 -0
  44. package/dist/resources/human/inbox.js +30 -0
  45. package/dist/resources/human/requests.d.ts +13 -0
  46. package/dist/resources/human/requests.js +26 -0
  47. package/dist/resources/human.d.ts +8 -0
  48. package/dist/resources/human.js +10 -0
  49. package/dist/resources/identifiers.d.ts +8 -0
  50. package/dist/resources/identifiers.js +33 -0
  51. package/dist/resources/memory.d.ts +69 -0
  52. package/dist/resources/memory.js +30 -0
  53. package/dist/resources/models/config.d.ts +8 -0
  54. package/dist/resources/models/config.js +9 -0
  55. package/dist/resources/models/credentials.d.ts +10 -0
  56. package/dist/resources/models/credentials.js +17 -0
  57. package/dist/resources/models.d.ts +8 -0
  58. package/dist/resources/models.js +10 -0
  59. package/dist/resources/node-stream.d.ts +34 -0
  60. package/dist/resources/node-stream.js +176 -0
  61. package/dist/resources/nodes/jobs.d.ts +9 -0
  62. package/dist/resources/nodes/jobs.js +14 -0
  63. package/dist/resources/nodes/result.d.ts +8 -0
  64. package/dist/resources/nodes/result.js +11 -0
  65. package/dist/resources/nodes/worktree.d.ts +9 -0
  66. package/dist/resources/nodes/worktree.js +14 -0
  67. package/dist/resources/nodes.d.ts +23 -0
  68. package/dist/resources/nodes.js +47 -0
  69. package/dist/resources/providers.d.ts +51 -0
  70. package/dist/resources/providers.js +15 -0
  71. package/dist/resources/questions.d.ts +49 -0
  72. package/dist/resources/questions.js +21 -0
  73. package/dist/resources/request.d.ts +7 -0
  74. package/dist/resources/request.js +19 -0
  75. package/dist/resources/run-notice.d.ts +34 -0
  76. package/dist/resources/run-notice.js +67 -0
  77. package/dist/resources/run-reply.d.ts +79 -0
  78. package/dist/resources/run-reply.js +93 -0
  79. package/dist/resources/run-stream.d.ts +65 -0
  80. package/dist/resources/run-stream.js +243 -0
  81. package/dist/resources/run-watcher.d.ts +98 -0
  82. package/dist/resources/run-watcher.js +162 -0
  83. package/dist/resources/runs.d.ts +192 -0
  84. package/dist/resources/runs.js +208 -0
  85. package/dist/resources/shares.d.ts +40 -0
  86. package/dist/resources/shares.js +19 -0
  87. package/dist/resources/structured-requests.d.ts +14 -0
  88. package/dist/resources/structured-requests.js +19 -0
  89. package/dist/resources/uploads.d.ts +27 -0
  90. package/dist/resources/uploads.js +11 -0
  91. package/dist/schema.d.ts +7 -0
  92. package/dist/schema.js +10 -0
  93. package/dist/stores/postgres.d.ts +32 -0
  94. package/dist/stores/postgres.js +92 -0
  95. package/dist/testing/cloud.d.ts +209 -0
  96. package/dist/testing/cloud.js +880 -0
  97. package/dist/testing/index.d.ts +5 -0
  98. package/dist/testing/index.js +3 -0
  99. package/dist/testing/notices.d.ts +70 -0
  100. package/dist/testing/notices.js +205 -0
  101. package/dist/testing/run.d.ts +172 -0
  102. package/dist/testing/run.js +429 -0
  103. package/dist/testing/users.d.ts +121 -0
  104. package/dist/testing/users.js +70 -0
  105. package/dist/testing/wire.d.ts +62 -0
  106. package/dist/testing/wire.js +148 -0
  107. package/dist/types.d.ts +80 -0
  108. package/dist/types.js +1 -0
  109. package/dist/version.d.ts +2 -0
  110. package/dist/version.js +3 -0
  111. package/package.json +57 -4
@@ -0,0 +1,202 @@
1
+ import { CrouterError } from '../errors.js';
2
+ import { refusalOf } from '../oauth/refusal.js';
3
+ const firstBackoffMs = 5_000;
4
+ const maxBackoffMs = 300_000;
5
+ /**
6
+ * The machinery `RunWatcher` and `GrantWatcher` share: the sweep loop (`sweepMs`, `nudge`, sweep-again coalescing),
7
+ * the queue and `concurrency` slots, per-key backoff, the lifetime `AbortController` and `stop()`, and `onError`.
8
+ * What a turn does, and whether it re-queues, is the watcher's.
9
+ */
10
+ export class Follower {
11
+ hooks;
12
+ following = new Map();
13
+ /** Keys queued at least once and not forgotten since. */
14
+ known = new Set();
15
+ /** Keys the last sweep read from `items()`. */
16
+ wanted = new Set();
17
+ /** Keys backing off after a failure, until this instant. */
18
+ retryAt = new Map();
19
+ backoff = new Map();
20
+ queue = [];
21
+ active = 0;
22
+ running = false;
23
+ timer;
24
+ sweeping;
25
+ sweepAgain = false;
26
+ lifetime = new AbortController();
27
+ constructor(hooks) {
28
+ this.hooks = hooks;
29
+ if (!Number.isFinite(hooks.sweepMs) || hooks.sweepMs <= 0)
30
+ throw new CrouterError('sweepMs must be a positive number');
31
+ if (!Number.isInteger(hooks.concurrency) || hooks.concurrency < 1)
32
+ throw new CrouterError('concurrency must be a positive integer');
33
+ }
34
+ get isRunning() { return this.running; }
35
+ /** Whether the last sweep still returned this key. */
36
+ wants(key) { return this.wanted.has(key); }
37
+ async start() {
38
+ if (this.running)
39
+ return;
40
+ if (this.lifetime.signal.aborted)
41
+ throw new CrouterError(`A stopped ${this.hooks.name} cannot be started again; build a new one`);
42
+ this.running = true;
43
+ await this.sweep();
44
+ }
45
+ nudge() { if (this.running)
46
+ void this.sweep(); }
47
+ async stop() {
48
+ this.running = false;
49
+ if (this.timer)
50
+ clearTimeout(this.timer);
51
+ this.lifetime.abort();
52
+ this.queue.length = 0;
53
+ await Promise.allSettled([...this.following.values()].map((follow) => follow.done));
54
+ }
55
+ /** A delivery landed for this key: clear its backoff. */
56
+ succeeded(key) {
57
+ this.retryAt.delete(key);
58
+ this.backoff.delete(key);
59
+ }
60
+ sweep() {
61
+ if (this.sweeping) {
62
+ this.sweepAgain = true;
63
+ return this.sweeping;
64
+ }
65
+ this.sweeping = (async () => {
66
+ do {
67
+ this.sweepAgain = false;
68
+ await this.sweepOnce();
69
+ } while (this.sweepAgain && this.running);
70
+ })().finally(() => {
71
+ this.sweeping = undefined;
72
+ if (this.running) {
73
+ if (this.timer)
74
+ clearTimeout(this.timer);
75
+ this.timer = setTimeout(() => void this.sweep(), this.hooks.sweepMs);
76
+ unref(this.timer);
77
+ }
78
+ });
79
+ return this.sweeping;
80
+ }
81
+ async sweepOnce() {
82
+ if (!this.running)
83
+ return;
84
+ let items;
85
+ try {
86
+ items = await this.hooks.items();
87
+ }
88
+ catch (error) {
89
+ this.report(error, null);
90
+ return;
91
+ }
92
+ if (!this.running)
93
+ return;
94
+ const wanted = new Map(items.map((item) => [this.hooks.key(item), item]));
95
+ this.wanted = new Set(wanted.keys());
96
+ // Forget keys the app no longer returns, so a later reuse of the key starts clean.
97
+ for (const key of new Set([...this.known, ...this.retryAt.keys()]))
98
+ if (!wanted.has(key))
99
+ this.forget(key);
100
+ for (let i = this.queue.length - 1; i >= 0; i--)
101
+ if (!wanted.has(this.hooks.key(this.queue[i])))
102
+ this.queue.splice(i, 1);
103
+ const now = Date.now();
104
+ for (const [key, item] of wanted) {
105
+ if (this.following.has(key) || this.queue.some((queued) => this.hooks.key(queued) === key))
106
+ continue;
107
+ if ((this.retryAt.get(key) ?? 0) > now)
108
+ continue;
109
+ if (this.hooks.admit && !this.hooks.admit(key))
110
+ continue;
111
+ this.known.add(key);
112
+ this.queue.push(item);
113
+ }
114
+ this.drain();
115
+ }
116
+ forget(key) {
117
+ this.known.delete(key);
118
+ this.retryAt.delete(key);
119
+ this.backoff.delete(key);
120
+ this.hooks.onForget?.(key);
121
+ }
122
+ drain() {
123
+ while (this.running && this.active < this.hooks.concurrency && this.queue.length > 0) {
124
+ const item = this.queue.shift();
125
+ const key = this.hooks.key(item);
126
+ const controller = new AbortController();
127
+ const abort = () => controller.abort(this.lifetime.signal.reason);
128
+ this.lifetime.signal.addEventListener('abort', abort, { once: true });
129
+ this.active++;
130
+ const follow = { item, done: Promise.resolve() };
131
+ this.following.set(key, follow);
132
+ follow.done = this.run(item, controller.signal).then((result) => {
133
+ this.lifetime.signal.removeEventListener('abort', abort);
134
+ this.active--;
135
+ if (this.following.get(key) === follow)
136
+ this.following.delete(key);
137
+ // The app stopped returning it during the turn: clear what the turn may have recorded.
138
+ if (!this.wanted.has(key))
139
+ this.forget(key);
140
+ // Back of the queue, so with more items than slots every item still gets a turn in order.
141
+ if (result === 'requeue' && this.running && this.wanted.has(key) && !this.queue.some((queued) => this.hooks.key(queued) === key))
142
+ this.queue.push(item);
143
+ this.drain();
144
+ });
145
+ }
146
+ }
147
+ async run(item, signal) {
148
+ let result;
149
+ try {
150
+ result = await this.hooks.turn(item, signal);
151
+ }
152
+ catch (error) {
153
+ this.report(error, item);
154
+ result = 'fail';
155
+ }
156
+ if (result === 'fail')
157
+ this.fail(this.hooks.key(item));
158
+ return result;
159
+ }
160
+ /** Back off this key: 5 s doubling to 5 min, then a sweep picks it up again. */
161
+ fail(key) {
162
+ const delay = Math.min(maxBackoffMs, this.backoff.get(key) ?? firstBackoffMs);
163
+ this.backoff.set(key, Math.min(maxBackoffMs, delay * 2));
164
+ this.retryAt.set(key, Date.now() + delay);
165
+ if (!this.running)
166
+ return;
167
+ const timer = setTimeout(() => { if (this.running)
168
+ void this.sweep(); }, delay);
169
+ unref(timer);
170
+ this.lifetime.signal.addEventListener('abort', () => clearTimeout(timer), { once: true });
171
+ }
172
+ report(error, item) {
173
+ try {
174
+ this.hooks.onError?.(error, item);
175
+ }
176
+ catch { /* A throwing onError must not stop the watcher. */ }
177
+ }
178
+ }
179
+ /** No stored connection, or a refusal `OAuth2Client` already handled: the item is dropped until the next sweep. */
180
+ export function isConnectionTrouble(error) {
181
+ return refusalOf(error) !== null || (error instanceof CrouterError && (error.code === 'not_connected' || error.code === 'refusal_handler_reentry'));
182
+ }
183
+ /** A turn's failure to open the person's client or read for them: reported to `onError`, then `drop` for connection
184
+ * trouble (the next sweep decides) or `fail` (back off) for anything else. */
185
+ export function troubleResult(follower, error, item) {
186
+ follower.report(error, item);
187
+ return isConnectionTrouble(error) ? 'drop' : 'fail';
188
+ }
189
+ /** Abortable pause; resolves early (without throwing) when `signal` aborts. */
190
+ export function pause(ms, signal) {
191
+ return new Promise((resolve) => {
192
+ if (signal.aborted) {
193
+ resolve();
194
+ return;
195
+ }
196
+ const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
197
+ const timer = setTimeout(done, ms);
198
+ unref(timer);
199
+ signal.addEventListener('abort', done, { once: true });
200
+ });
201
+ }
202
+ function unref(timer) { timer.unref?.(); }
@@ -0,0 +1,7 @@
1
+ import type { Crouter } from '../client.js';
2
+ /** Native EventSource sends Last-Event-ID on reconnect; it takes precedence over the URL cursor. */
3
+ export declare function cursorFromRequest(request: Request): number;
4
+ export declare function forwardRunEvents(crouter: Crouter, runId: string, options: {
5
+ after: number;
6
+ signal?: AbortSignal;
7
+ }): Response;
@@ -0,0 +1,67 @@
1
+ import { APIError } from '../errors.js';
2
+ /** Native EventSource sends Last-Event-ID on reconnect; it takes precedence over the URL cursor. */
3
+ export function cursorFromRequest(request) {
4
+ const header = request.headers.get('Last-Event-ID');
5
+ const value = header !== null ? header : new URL(request.url).searchParams.get('after') ?? '0';
6
+ const cursor = Number(value);
7
+ if (!/^(0|[1-9]\d*)$/.test(value) || !Number.isSafeInteger(cursor))
8
+ throw new RangeError('Invalid event cursor');
9
+ return cursor;
10
+ }
11
+ export function forwardRunEvents(crouter, runId, options) {
12
+ const encoder = new TextEncoder();
13
+ const envelope = (failure) => ({
14
+ code: failure.code, message: failure.message,
15
+ ...(failure.type !== undefined ? { type: failure.type } : {}),
16
+ ...(failure.param !== undefined ? { param: failure.param } : {}),
17
+ ...(failure.details !== undefined ? { details: failure.details } : {}),
18
+ ...(failure.retryable !== undefined ? { retryable: failure.retryable } : {}),
19
+ ...(failure.retryAfterS !== undefined ? { retry_after_s: failure.retryAfterS } : {}),
20
+ ...(failure.resetAt !== undefined ? { reset_at: failure.resetAt } : {}),
21
+ ...(failure.userAction !== undefined ? { user_action: failure.userAction } : {}),
22
+ ...(failure.requestId !== undefined ? { request_id: failure.requestId } : {}),
23
+ ...(failure.runId !== undefined ? { run_id: failure.runId } : {}),
24
+ ...(failure.questionId !== undefined ? { question_id: failure.questionId } : {}),
25
+ ...(failure.scope !== undefined ? { scope: failure.scope } : {}),
26
+ ...(failure.origin !== undefined ? { origin: failure.origin } : {}),
27
+ });
28
+ const failureEnvelope = (error) => envelope(error instanceof APIError ? error : new APIError(0, 'transport_error', String(error), undefined, undefined, { retryable: true }));
29
+ let notify = () => undefined;
30
+ const source = crouter.runs.events(runId, { ...options, onReconnect: (state, error) => notify(state, error) });
31
+ let closed = false;
32
+ const stream = new ReadableStream({
33
+ start(controller) {
34
+ const write = (text) => { if (!closed)
35
+ controller.enqueue(encoder.encode(text)); };
36
+ notify = (state, error) => write(`data: ${JSON.stringify(state === 'reconnected' ? { type: 'stream.reconnected' } : { type: 'stream.reconnecting', error: failureEnvelope(error) })}\n\n`);
37
+ const keepalive = setInterval(() => write(': keepalive\n\n'), 15_000);
38
+ void (async () => {
39
+ try {
40
+ for await (const event of source) {
41
+ write(`id: ${event.sequence_number}\ndata: ${JSON.stringify(event)}\n\n`);
42
+ if (event.type === 'node.settled')
43
+ break;
44
+ }
45
+ // The runtime closed an idle stream: tell the browser, then close, so an auto-reconnecting EventSource does not reopen at once.
46
+ if (source.idle)
47
+ write(`data: ${JSON.stringify({ type: 'stream.idle', cursor: source.position })}\n\n`);
48
+ }
49
+ catch (error) {
50
+ if (options.signal?.aborted)
51
+ return;
52
+ write(`data: ${JSON.stringify({ type: 'stream.refused', error: failureEnvelope(error) })}\n\n`);
53
+ }
54
+ finally {
55
+ source.abort();
56
+ clearInterval(keepalive);
57
+ if (!closed) {
58
+ closed = true;
59
+ controller.close();
60
+ }
61
+ }
62
+ })();
63
+ },
64
+ cancel() { closed = true; source.abort(); },
65
+ });
66
+ return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' } });
67
+ }
@@ -0,0 +1,57 @@
1
+ import type { GrantGetDTO } from '@north-light/api';
2
+ import type { Crouter } from '../client.js';
3
+ /** One grant reading `GrantWatcher` hands to `onChanged`. `previous` is the grant last delivered for this user in this
4
+ * process, or `null` on the first delivery (also after a restart, or after the user left and re-entered `users()`). */
5
+ export interface GrantChanged {
6
+ userId: string;
7
+ grant: GrantGetDTO;
8
+ previous: GrantGetDTO | null;
9
+ }
10
+ export interface GrantWatchOptions {
11
+ /** The people whose grant the app follows (crouter cloud user ids), read from its own rows. Called at `start()`,
12
+ * every `sweepMs`, after each delivery, and on `nudge()`. Return only people whose change the app will act on:
13
+ * each followed person holds one request on their runtime per turn. A user no longer returned is forgotten. */
14
+ users: () => Promise<string[]> | string[];
15
+ /** Level-triggered: decide from `event.grant` (and the app's rows), never from "did it just change". The first event
16
+ * per user in a process carries the current grant with `previous: null`. At least once per `(userId, version)`:
17
+ * make it idempotent. If it throws, the same version is delivered again after 5 s, doubling to 5 min. */
18
+ onChanged: (event: GrantChanged) => Promise<void> | void;
19
+ /** Told about every failure the watcher recovers from itself: a throwing `users()` or `onChanged`, a transient
20
+ * runtime error, a missing (`not_connected`) or refused connection. Defaults to ignoring them. */
21
+ onError?: (error: unknown, userId: string | null) => void;
22
+ /** Milliseconds between sweeps of `users()`. Default 30 000. */
23
+ sweepMs?: number;
24
+ /** Most users read at once. Default 50; the rest wait their turn, round-robin. */
25
+ concurrency?: number;
26
+ /** Seconds each held read waits for a change: an integer 1–25, default 25. */
27
+ wait?: number;
28
+ }
29
+ /**
30
+ * Follows the grants of the people an app names and hands each change to `onChanged` at least once. Build it with
31
+ * `oauth.watchGrants(...)`, then `start()`.
32
+ *
33
+ * One turn per user is one `grant.get`; then the user goes to the back of the queue. A user whose last answer was
34
+ * delivered reads `{wait, since: version}`, so the runtime holds the read until the grant changes; a user with nothing
35
+ * delivered yet (first turn, a throwing `onChanged`) reads at once. Delivered versions are kept in memory: the app's
36
+ * rows are the durable record, and a restarted watcher delivers each user's current grant again.
37
+ */
38
+ export declare class GrantWatcher {
39
+ private readonly open;
40
+ private readonly options;
41
+ private readonly wait;
42
+ /** The grant last delivered per user. */
43
+ private readonly delivered;
44
+ /** The version last read per user: a user whose last reading was not delivered reads again without `wait`. */
45
+ private readonly seen;
46
+ /** Users whose last held read answered at once with the same version: their next turn pauses first. */
47
+ private readonly unheld;
48
+ private readonly follower;
49
+ constructor(open: (userId: string) => Promise<Crouter>, options: GrantWatchOptions);
50
+ /** Read `users()` and begin following; sweeps repeat every `sweepMs` until `stop()`. Resolves after the first sweep. */
51
+ start(): Promise<void>;
52
+ /** Sweep now instead of waiting for `sweepMs`, for example right after the app starts waiting on someone. */
53
+ nudge(): void;
54
+ /** Stop sweeping, abort every open read, and resolve once every turn has ended. No handler starts afterwards. */
55
+ stop(): Promise<void>;
56
+ private turn;
57
+ }
@@ -0,0 +1,100 @@
1
+ import { CrouterError } from '../errors.js';
2
+ import { Follower, pause, troubleResult } from './follower.js';
3
+ /** A held read that answers its unchanged `since` sooner than this is treated as not holding: the next turn waits. */
4
+ const minHoldMs = 1_000;
5
+ const unheldRetryMs = 1_000;
6
+ /**
7
+ * Follows the grants of the people an app names and hands each change to `onChanged` at least once. Build it with
8
+ * `oauth.watchGrants(...)`, then `start()`.
9
+ *
10
+ * One turn per user is one `grant.get`; then the user goes to the back of the queue. A user whose last answer was
11
+ * delivered reads `{wait, since: version}`, so the runtime holds the read until the grant changes; a user with nothing
12
+ * delivered yet (first turn, a throwing `onChanged`) reads at once. Delivered versions are kept in memory: the app's
13
+ * rows are the durable record, and a restarted watcher delivers each user's current grant again.
14
+ */
15
+ export class GrantWatcher {
16
+ open;
17
+ options;
18
+ wait;
19
+ /** The grant last delivered per user. */
20
+ delivered = new Map();
21
+ /** The version last read per user: a user whose last reading was not delivered reads again without `wait`. */
22
+ seen = new Map();
23
+ /** Users whose last held read answered at once with the same version: their next turn pauses first. */
24
+ unheld = new Set();
25
+ follower;
26
+ constructor(open, options) {
27
+ this.open = open;
28
+ this.options = options;
29
+ this.wait = options.wait ?? 25;
30
+ if (!Number.isInteger(this.wait) || this.wait < 1 || this.wait > 25)
31
+ throw new CrouterError('wait must be an integer from 1 to 25');
32
+ this.follower = new Follower({
33
+ name: 'GrantWatcher',
34
+ items: () => options.users(),
35
+ key: (userId) => userId,
36
+ turn: (userId, signal) => this.turn(userId, signal),
37
+ onForget: (userId) => { this.delivered.delete(userId); this.seen.delete(userId); this.unheld.delete(userId); },
38
+ onError: options.onError,
39
+ sweepMs: options.sweepMs ?? 30_000,
40
+ concurrency: options.concurrency ?? 50,
41
+ });
42
+ }
43
+ /** Read `users()` and begin following; sweeps repeat every `sweepMs` until `stop()`. Resolves after the first sweep. */
44
+ start() { return this.follower.start(); }
45
+ /** Sweep now instead of waiting for `sweepMs`, for example right after the app starts waiting on someone. */
46
+ nudge() { this.follower.nudge(); }
47
+ /** Stop sweeping, abort every open read, and resolve once every turn has ended. No handler starts afterwards. */
48
+ stop() { return this.follower.stop(); }
49
+ async turn(userId, signal) {
50
+ if (this.unheld.delete(userId))
51
+ await pause(unheldRetryMs, signal);
52
+ if (signal.aborted || !this.follower.isRunning)
53
+ return 'park';
54
+ let client;
55
+ try {
56
+ client = await this.open(userId);
57
+ }
58
+ catch (error) {
59
+ return troubleResult(this.follower, error, userId);
60
+ }
61
+ const last = this.delivered.get(userId);
62
+ const started = Date.now();
63
+ let grant;
64
+ // Hold only when the last reading was delivered; otherwise (first turn, a throwing onChanged) read at once.
65
+ const hold = last !== undefined && this.seen.get(userId) === last.version;
66
+ try {
67
+ grant = await client.grant.get(hold ? { signal, wait: this.wait, since: last.version } : { signal });
68
+ }
69
+ catch (error) {
70
+ if (signal.aborted || !this.follower.isRunning)
71
+ return 'park';
72
+ return troubleResult(this.follower, error, userId);
73
+ }
74
+ if (typeof grant?.version !== 'string' || grant.version === '') {
75
+ this.follower.report(new CrouterError('grant.get answered without a version; the runtime predates the grant watch'), userId);
76
+ return 'fail';
77
+ }
78
+ this.seen.set(userId, grant.version);
79
+ if (last && grant.version === last.version) {
80
+ if (hold && Date.now() - started < minHoldMs)
81
+ this.unheld.add(userId);
82
+ return 'requeue';
83
+ }
84
+ if (!this.follower.isRunning || !this.follower.wants(userId))
85
+ return 'park';
86
+ try {
87
+ await this.options.onChanged({ userId, grant, previous: last ?? null });
88
+ }
89
+ catch (error) {
90
+ this.follower.report(error, userId);
91
+ return 'fail';
92
+ }
93
+ this.delivered.set(userId, grant);
94
+ this.follower.succeeded(userId);
95
+ // The app has likely acted on it; re-read users() so the set shrinks promptly.
96
+ if (this.follower.isRunning)
97
+ void this.follower.sweep();
98
+ return 'requeue';
99
+ }
100
+ }
@@ -0,0 +1,14 @@
1
+ import { type CancelInboxTicketRequest, type CanceledTicketResultDTO, type InboxListDTO, type InboxPageDTO, type InboxPageHistoryDTO, type InboxPageProgressDTO, type InboxPageResponseDTO, type PageTicketResultDTO, type RespondInboxPageRequest } from '@north-light/api';
2
+ import type { RequestOptions } from '../../types.js';
3
+ import type { Request } from '../request.js';
4
+ export declare class HumanInbox {
5
+ private readonly request;
6
+ constructor(request: Request);
7
+ list(options?: RequestOptions): Promise<InboxListDTO>;
8
+ retrieve(ticketId: string, options?: RequestOptions): Promise<InboxPageDTO>;
9
+ respond(ticketId: string, body: RespondInboxPageRequest, options?: RequestOptions): Promise<PageTicketResultDTO>;
10
+ progress(ticketId: string, body: InboxPageProgressDTO, options?: RequestOptions): Promise<Record<string, unknown>>;
11
+ cancel(ticketId: string, body?: CancelInboxTicketRequest, options?: RequestOptions): Promise<CanceledTicketResultDTO>;
12
+ history(nodeId?: string, options?: RequestOptions): Promise<InboxPageHistoryDTO>;
13
+ response(ticketId: string, options?: RequestOptions): Promise<InboxPageResponseDTO>;
14
+ }
@@ -0,0 +1,30 @@
1
+ import { routes, } from '@north-light/api';
2
+ import { inboxTicketId } from '../identifiers.js';
3
+ import { withQuery } from '../request.js';
4
+ export class HumanInbox {
5
+ request;
6
+ constructor(request) {
7
+ this.request = request;
8
+ }
9
+ list(options) {
10
+ return this.request('GET', routes.humanInbox(), undefined, options);
11
+ }
12
+ async retrieve(ticketId, options) {
13
+ return this.request('GET', routes.humanInboxTicket(inboxTicketId(ticketId)), undefined, options);
14
+ }
15
+ async respond(ticketId, body, options) {
16
+ return this.request('POST', routes.humanInboxRespond(inboxTicketId(ticketId)), body, options);
17
+ }
18
+ async progress(ticketId, body, options) {
19
+ return this.request('POST', routes.humanInboxProgress(inboxTicketId(ticketId)), body, options);
20
+ }
21
+ async cancel(ticketId, body, options) {
22
+ return this.request('POST', routes.humanInboxCancel(inboxTicketId(ticketId)), body ?? {}, options);
23
+ }
24
+ history(nodeId, options) {
25
+ return this.request('GET', withQuery('/v1/human/inbox/history', nodeId === undefined ? undefined : { node_id: nodeId }), undefined, options);
26
+ }
27
+ async response(ticketId, options) {
28
+ return this.request('GET', routes.humanInboxResponse(inboxTicketId(ticketId)), undefined, options);
29
+ }
30
+ }
@@ -0,0 +1,13 @@
1
+ import { type CreateHumanRequestDTO, type CreateHumanRequestRequest, type HumanRequestDTO, type ReplaceHumanRequestRequest, type RespondHumanRequestRequest, type SettleHumanRequestRequest } from '@north-light/api';
2
+ import type { RequestOptions } from '../../types.js';
3
+ import type { Request } from '../request.js';
4
+ export declare class HumanRequests {
5
+ private readonly request;
6
+ constructor(request: Request);
7
+ create(body: CreateHumanRequestRequest, options?: RequestOptions): Promise<CreateHumanRequestDTO>;
8
+ retrieve(id: string, options?: RequestOptions): Promise<HumanRequestDTO>;
9
+ replace(id: string, body: ReplaceHumanRequestRequest, options?: RequestOptions): Promise<HumanRequestDTO>;
10
+ respond(id: string, body: RespondHumanRequestRequest, options?: RequestOptions): Promise<HumanRequestDTO>;
11
+ dismiss(id: string, body?: SettleHumanRequestRequest, options?: RequestOptions): Promise<HumanRequestDTO>;
12
+ cancel(id: string, body?: SettleHumanRequestRequest, options?: RequestOptions): Promise<HumanRequestDTO>;
13
+ }
@@ -0,0 +1,26 @@
1
+ import { routes, } from '@north-light/api';
2
+ import { requestId } from '../identifiers.js';
3
+ export class HumanRequests {
4
+ request;
5
+ constructor(request) {
6
+ this.request = request;
7
+ }
8
+ create(body, options) {
9
+ return this.request('POST', routes.humanRequests(), body, options);
10
+ }
11
+ async retrieve(id, options) {
12
+ return this.request('GET', routes.humanRequest(requestId(id)), undefined, options);
13
+ }
14
+ async replace(id, body, options) {
15
+ return this.request('POST', routes.humanRequestReplace(requestId(id)), body, options);
16
+ }
17
+ async respond(id, body, options) {
18
+ return this.request('POST', routes.humanRequestRespond(requestId(id)), body, options);
19
+ }
20
+ async dismiss(id, body, options) {
21
+ return this.request('POST', routes.humanRequestDismiss(requestId(id)), body ?? {}, options);
22
+ }
23
+ async cancel(id, body, options) {
24
+ return this.request('POST', routes.humanRequestCancel(requestId(id)), body ?? {}, options);
25
+ }
26
+ }
@@ -0,0 +1,8 @@
1
+ import { HumanInbox } from './human/inbox.js';
2
+ import { HumanRequests } from './human/requests.js';
3
+ import type { Request } from './request.js';
4
+ export declare class Human {
5
+ readonly requests: HumanRequests;
6
+ readonly inbox: HumanInbox;
7
+ constructor(request: Request);
8
+ }
@@ -0,0 +1,10 @@
1
+ import { HumanInbox } from './human/inbox.js';
2
+ import { HumanRequests } from './human/requests.js';
3
+ export class Human {
4
+ requests;
5
+ inbox;
6
+ constructor(request) {
7
+ this.requests = new HumanRequests(request);
8
+ this.inbox = new HumanInbox(request);
9
+ }
10
+ }
@@ -0,0 +1,8 @@
1
+ export declare class ResourceIdentifierError extends TypeError {
2
+ }
3
+ export declare function nodeId(value: string): string;
4
+ export declare function requestId(value: string): string;
5
+ export declare function cronId(value: string): string;
6
+ export declare function jobId(value: string): string;
7
+ export declare function inboxTicketId(value: string): string;
8
+ export declare function pathSegment(value: string, label: string): string;
@@ -0,0 +1,33 @@
1
+ import { isSafeBashJobId, isSafeCronId, isSafeNodeId } from '@north-light/api';
2
+ export class ResourceIdentifierError extends TypeError {
3
+ }
4
+ function validated(value, label, validator) {
5
+ if (!validator(value) || !isSafePathSegment(value)) {
6
+ throw new ResourceIdentifierError(`invalid ${label}: ${JSON.stringify(value)}`);
7
+ }
8
+ return value;
9
+ }
10
+ function isSafePathSegment(value) {
11
+ return typeof value === 'string'
12
+ && value !== '' && value !== '.' && value !== '..'
13
+ && !/[\\/\0\s?#%]/u.test(value)
14
+ && new TextEncoder().encode(value).byteLength <= 128;
15
+ }
16
+ export function nodeId(value) {
17
+ return validated(value, 'node id', isSafeNodeId);
18
+ }
19
+ export function requestId(value) {
20
+ return validated(value, 'request id', isSafeNodeId);
21
+ }
22
+ export function cronId(value) {
23
+ return validated(value, 'cron id', isSafeCronId);
24
+ }
25
+ export function jobId(value) {
26
+ return validated(value, 'job id', isSafeBashJobId);
27
+ }
28
+ export function inboxTicketId(value) {
29
+ return validated(value, 'inbox ticket id', (ticketId) => /^[a-f0-9]{64}$/u.test(ticketId));
30
+ }
31
+ export function pathSegment(value, label) {
32
+ return validated(value, label, isSafePathSegment);
33
+ }
@@ -0,0 +1,69 @@
1
+ import type { RequestOptions } from '../types.js';
2
+ import { type Request } from './request.js';
3
+ /** `memory.list` input. Only the documents the person owns (`store: 'user'`) are served in this release. */
4
+ export interface MemoryListParams {
5
+ store: 'user';
6
+ /** A folder: documents whose name starts with `<path>/`, at any depth. */
7
+ path?: string;
8
+ kind?: 'knowledge' | 'preference';
9
+ /** 1 to 100; default 50. */
10
+ limit?: number;
11
+ /** The previous page's `next_cursor`, unchanged, with the same `store`, `path` and `kind`. */
12
+ cursor?: string;
13
+ }
14
+ /** `memory.get` input. */
15
+ export interface MemoryGetParams {
16
+ store: 'user';
17
+ /** The document's full name, as `memory.list` returns it. */
18
+ name: string;
19
+ }
20
+ /** One document the person owns, as listed. */
21
+ export interface MemoryListItem {
22
+ /** The full name without the `user/` owner prefix. */
23
+ name: string;
24
+ /** The name up to its last `/`; `""` for a top-level document. */
25
+ folder: string;
26
+ kind: 'knowledge' | 'preference';
27
+ preview: string;
28
+ summary: string | null;
29
+ /** Without a `crtr:memory:manage:user` grant an app only ever sees `public`. */
30
+ privacy: 'public' | 'friends' | 'private';
31
+ /** The current revision number. */
32
+ revision: number;
33
+ /** The time of revision 1. */
34
+ created_at: string;
35
+ /** The time of the current revision. */
36
+ updated_at: string;
37
+ }
38
+ /** A page of `memory.list`, ordered by name. */
39
+ export interface MemoryListPage {
40
+ items: MemoryListItem[];
41
+ /** `null` on the last page. */
42
+ next_cursor: string | null;
43
+ }
44
+ /** A document with its body, from `memory.get`. */
45
+ export interface MemoryDocument extends MemoryListItem {
46
+ /** The current markdown body as stored; links are not resolved. At most 1 MiB. */
47
+ body: string;
48
+ /** The full body's length in UTF-8 bytes. */
49
+ size: number;
50
+ /** `true` when `size` is over 1 MiB and `body` is its prefix. */
51
+ truncated: boolean;
52
+ /** `app:<id>` of the app whose node wrote revision 1; `null` when the person wrote it. */
53
+ grantee: string | null;
54
+ }
55
+ /**
56
+ * Reads the documents the person owns. The daemon filters each one by the caller's
57
+ * grant: an app reads `public` documents with no scope, and every privacy level with
58
+ * `crtr:memory:manage:user`; without it a `friends` or `private` document reads
59
+ * exactly as an absent one (`NotFoundError`). Reading records nothing.
60
+ */
61
+ export declare class Memory {
62
+ private readonly request;
63
+ constructor(request: Request);
64
+ list(params: MemoryListParams, options?: RequestOptions): Promise<MemoryListPage>;
65
+ /** Every document across pages, ordered by name: `for await (const item of memory.listAll({store: 'user'}))`.
66
+ * Fetches the next page (`limit` per page) only as you iterate; `break` stops fetching. */
67
+ listAll(params: Omit<MemoryListParams, 'cursor'>, options?: RequestOptions): AsyncGenerator<MemoryListItem, void, undefined>;
68
+ get(params: MemoryGetParams, options?: RequestOptions): Promise<MemoryDocument>;
69
+ }