@nurix/nustack 0.23.4 → 0.23.6

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.
@@ -235,6 +235,80 @@ const MIGRATION_V8 = {
235
235
  `CREATE UNIQUE INDEX record_store_idempotency_key_unique ON record_store (idempotency_key)`,
236
236
  ],
237
237
  };
238
+ const V9_LEGACY_BOOKKEEPING_ORIGIN = "'session:legacy-bookkeeping'";
239
+ const V9_BOOKKEEPING_ORIGIN = `(CASE WHEN json_type(row_json, '$.planAnchor') = 'text'
240
+ THEN 'plan:' || json_extract(row_json, '$.planAnchor') ELSE ${V9_LEGACY_BOOKKEEPING_ORIGIN} END)`;
241
+ const V9_NEW_KIND = `(CASE kind WHEN 'backlog.remove' THEN 'backlog' WHEN 'bookkeeping' THEN 'obligation' ELSE kind END)`;
242
+ const V9_NATURAL_KEY = `COALESCE(CASE kind
243
+ WHEN 'bookkeeping' THEN ${V9_BOOKKEEPING_ORIGIN} || '#documentation:' || json_extract(row_json, '$.anchor')
244
+ ELSE json_extract(row_json, '$.anchor') END, '')`;
245
+ const V9_PAYLOAD = `(CASE kind
246
+ WHEN 'backlog' THEN json_remove(row_json, '$.idempotencyKey', '$.planAnchor')
247
+ WHEN 'backlog.remove' THEN json_set(json_remove(row_json, '$.idempotencyKey', '$.planAnchor'), '$.state', 'removed')
248
+ WHEN 'bookkeeping' THEN (CASE WHEN json_type(row_json, '$.text') = 'text'
249
+ THEN json_object('origin', ${V9_BOOKKEEPING_ORIGIN}, 'type', 'documentation',
250
+ 'subject', json_extract(row_json, '$.anchor'), 'claim', json_extract(row_json, '$.text'), 'state', 'open')
251
+ ELSE json_object('origin', ${V9_BOOKKEEPING_ORIGIN}, 'type', 'documentation',
252
+ 'subject', json_extract(row_json, '$.anchor'), 'state', 'open') END)
253
+ ELSE json_remove(row_json, '$.idempotencyKey') END)`;
254
+ const V9_SOURCE = `(
255
+ SELECT id, project_id, repository_root, NULL AS door_url, 0 AS attempts, NULL AS next_attempt_at,
256
+ NULL AS last_error, created_at_ms, updated_at_ms, json_extract(row_json, '$.planAnchor') AS plan_anchor,
257
+ ${V9_NEW_KIND} AS new_kind, ${V9_NATURAL_KEY} AS natural_key, ${V9_PAYLOAD} AS payload_json
258
+ FROM record_store
259
+ UNION ALL
260
+ SELECT id, project_id, NULL AS repository_root, NULLIF(door_url, '') AS door_url, attempts,
261
+ next_retry_at_ms AS next_attempt_at, last_error, created_at_ms, updated_at_ms,
262
+ json_extract(row_json, '$.planAnchor') AS plan_anchor,
263
+ ${V9_NEW_KIND} AS new_kind, ${V9_NATURAL_KEY} AS natural_key, ${V9_PAYLOAD} AS payload_json
264
+ FROM record_outbox
265
+ )`;
266
+ const V9_KEY = `COALESCE(project_id, repository_root) || ':' || new_kind || ':' || natural_key`;
267
+ const MIGRATION_V9 = {
268
+ version: 9,
269
+ statements: [
270
+ `CREATE TABLE records (
271
+ id TEXT PRIMARY KEY,
272
+ kind TEXT NOT NULL,
273
+ schema_version INTEGER NOT NULL,
274
+ project_id TEXT,
275
+ session_id TEXT,
276
+ plan_anchor TEXT,
277
+ natural_key TEXT NOT NULL,
278
+ idempotency_key TEXT NOT NULL,
279
+ payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),
280
+ sync_state TEXT NOT NULL,
281
+ attempts INTEGER NOT NULL DEFAULT 0,
282
+ next_attempt_at INTEGER,
283
+ last_error TEXT,
284
+ created_at INTEGER NOT NULL,
285
+ repository_root TEXT,
286
+ door_url TEXT,
287
+ placement TEXT,
288
+ credential TEXT
289
+ )`,
290
+ `CREATE UNIQUE INDEX records_idempotency_key_unique ON records (idempotency_key)`,
291
+ `CREATE INDEX records_sync_state_next_attempt_idx ON records (sync_state, next_attempt_at)`,
292
+ `INSERT OR REPLACE INTO records (id, kind, schema_version, project_id, session_id, plan_anchor, natural_key,
293
+ idempotency_key, payload_json, sync_state, attempts, next_attempt_at, last_error, created_at,
294
+ repository_root, door_url, placement, credential)
295
+ SELECT id, new_kind, 1, project_id, NULL, plan_anchor, natural_key, ${V9_KEY}, payload_json, 'pending',
296
+ attempts, next_attempt_at, last_error, created_at_ms, repository_root, door_url, NULL, NULL
297
+ FROM ${V9_SOURCE}
298
+ ORDER BY created_at_ms, updated_at_ms`,
299
+ `CREATE TEMP TABLE records_v9_guard (shortfall INTEGER NOT NULL CONSTRAINT records_v9_guard CHECK (shortfall = 0))`,
300
+ `INSERT INTO records_v9_guard SELECT count(*) FROM records
301
+ WHERE kind = 'backlog' AND json_extract(payload_json, '$.anchor') IS NULL`,
302
+ `INSERT INTO records_v9_guard SELECT count(*) FROM records
303
+ WHERE kind = 'obligation' AND (json_extract(payload_json, '$.origin') IS NULL
304
+ OR json_extract(payload_json, '$.subject') IS NULL OR json_extract(payload_json, '$.state') IS NULL)`,
305
+ `INSERT INTO records_v9_guard SELECT
306
+ (SELECT count(DISTINCT ${V9_KEY}) FROM ${V9_SOURCE}) - (SELECT count(*) FROM records)`,
307
+ `DROP TABLE records_v9_guard`,
308
+ `DROP TABLE record_outbox`,
309
+ `DROP TABLE record_store`,
310
+ ],
311
+ };
238
312
  export const MIGRATIONS = [
239
313
  MIGRATION_V1,
240
314
  MIGRATION_V2,
@@ -244,6 +318,7 @@ export const MIGRATIONS = [
244
318
  MIGRATION_V6,
245
319
  MIGRATION_V7,
246
320
  MIGRATION_V8,
321
+ MIGRATION_V9,
247
322
  ];
248
323
  export const LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
249
324
  export class LocalSchemaTooNewError extends Error {
@@ -269,6 +344,10 @@ export function migrateToLatest(db) {
269
344
  if (migration.version <= current)
270
345
  continue;
271
346
  db.exec("BEGIN IMMEDIATE");
347
+ if (readUserVersion(db) >= migration.version) {
348
+ db.exec("COMMIT");
349
+ continue;
350
+ }
272
351
  try {
273
352
  for (const statement of migration.statements) {
274
353
  db.exec(statement);
@@ -10,6 +10,12 @@ export function stateDbPath() {
10
10
  export function locksDir() {
11
11
  return path.join(nustackHomeDir(), "locks");
12
12
  }
13
+ export function recordsDrainLockPath() {
14
+ return path.join(locksDir(), "records-drain.lock");
15
+ }
16
+ export function desktopSettingsPath() {
17
+ return path.join(nustackHomeDir(), "desktop-settings.json");
18
+ }
13
19
  export function cacheDir() {
14
20
  return path.join(nustackHomeDir(), "cache");
15
21
  }
@@ -1,22 +1,28 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { existsSync } from "node:fs";
3
2
  import { readFile } from "node:fs/promises";
4
3
  import path from "node:path";
5
- import { ensureAccessToken } from "../auth_grant.js";
6
4
  import { resolveNuStackUrl } from "../nustack_client.js";
7
5
  import { resolveProjectId } from "../project_context.js";
8
- export const RECORD_KINDS = ["backlog", "backlog.remove", "bookkeeping", "gate_event"];
6
+ export const RECORD_KINDS = ["obligation", "gate_event", "changelog", "memory", "backlog", "proposal", "triage"];
7
+ export const LEGACY_KIND_TRANSLATION = { "backlog.remove": "backlog", bookkeeping: "obligation" };
9
8
  export const RECORDS_BATCH_CAP = 200;
9
+ export const RECORD_SCHEMA_VERSION = 1;
10
10
  const REQUEST_TIMEOUT_MS = 10_000;
11
11
  const RETRY_BASE_MS = 30_000;
12
12
  const RETRY_CEILING_MS = 60 * 60 * 1000;
13
13
  export function isRecordKind(value) {
14
14
  return typeof value === "string" && RECORD_KINDS.includes(value);
15
15
  }
16
- export function idempotencyKeyOf(project, kind, anchor) {
17
- return `${project}:${kind}:${anchor}`;
16
+ export function isLegacyKind(value) {
17
+ return typeof value === "string" && Object.hasOwn(LEGACY_KIND_TRANSLATION, value);
18
18
  }
19
- async function readMountDoor(root) {
19
+ export function isEmitKind(value) {
20
+ return isRecordKind(value) || isLegacyKind(value);
21
+ }
22
+ export function translatedKindOf(kind) {
23
+ return isLegacyKind(kind) ? LEGACY_KIND_TRANSLATION[kind] : kind;
24
+ }
25
+ export async function readMountDoor(root) {
20
26
  const manifest = path.join(root, ".claude", "harness.json");
21
27
  if (!existsSync(manifest))
22
28
  return undefined;
@@ -40,184 +46,27 @@ export async function resolveDoor(root, globals, projectFlag) {
40
46
  return { kind: "store", projectId: project.projectId, root, reason: "the harness manifest configures no door" };
41
47
  return { kind: "door", projectId: project.projectId, doorUrl: door ?? resolveNuStackUrl(globals), isPlatformDefault: door === undefined, root };
42
48
  }
43
- export class DoorRefusedError extends Error {
49
+ export function retryDelayMs(attempts) {
50
+ return Math.min(RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1), RETRY_CEILING_MS);
44
51
  }
45
- export async function postRecords(doorUrl, projectId, kind, rows) {
46
- const auth = await ensureAccessToken().catch(() => null);
47
- if (!auth)
48
- throw new DoorRefusedError("this device is signed out");
52
+ export async function postRecords(doorUrl, projectId, request, bearer) {
49
53
  let response;
50
54
  try {
51
55
  response = await fetch(`${doorUrl}/api/v2/projects/${encodeURIComponent(projectId)}/records`, {
52
56
  method: "POST",
53
- headers: { "content-type": "application/json", authorization: `Bearer ${auth.accessToken}` },
54
- body: JSON.stringify({ kind, rows }),
57
+ headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
58
+ body: JSON.stringify(request),
55
59
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
56
60
  });
57
61
  }
58
62
  catch (error) {
59
- throw new DoorRefusedError(`the door did not answer: ${error instanceof Error ? error.message : String(error)}`);
63
+ return { ok: false, status: 0, code: null, message: `the door did not answer: ${error instanceof Error ? error.message : String(error)}` };
60
64
  }
61
- const payload = (await response.json().catch(() => null));
65
+ const body = (await response.json().catch(() => null));
62
66
  if (!response.ok) {
63
- const detail = payload?.error?.message ?? `HTTP ${response.status}`;
64
- throw new DoorRefusedError(`${payload?.error?.code ?? `HTTP ${response.status}`}: ${detail}`);
65
- }
66
- return { landed: payload?.data?.landed ?? 0, skipped: payload?.data?.skipped ?? 0 };
67
- }
68
- function outboxOf(row) {
69
- return {
70
- id: row.id,
71
- projectId: row.project_id,
72
- kind: row.kind,
73
- idempotencyKey: row.idempotency_key,
74
- doorUrl: row.door_url,
75
- row: JSON.parse(row.row_json),
76
- attempts: row.attempts,
77
- nextRetryAtMs: row.next_retry_at_ms ?? null,
78
- lastError: row.last_error ?? null,
79
- createdAtMs: row.created_at_ms,
80
- };
81
- }
82
- function storedOf(row) {
83
- return {
84
- id: row.id,
85
- projectId: row.project_id ?? null,
86
- repositoryRoot: row.repository_root,
87
- kind: row.kind,
88
- idempotencyKey: row.idempotency_key,
89
- row: JSON.parse(row.row_json),
90
- createdAtMs: row.created_at_ms,
91
- };
92
- }
93
- export function retryDelayMs(attempts) {
94
- return Math.min(RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1), RETRY_CEILING_MS);
95
- }
96
- export function enqueueOutbox(store, input) {
97
- const now = Date.now();
98
- const key = idempotencyKeyOf(input.projectId, input.kind, input.row.anchor);
99
- store.db
100
- .prepare(`INSERT INTO record_outbox (id, project_id, kind, idempotency_key, door_url, row_json, attempts,
101
- next_retry_at_ms, last_error, created_at_ms, updated_at_ms)
102
- VALUES (:id, :project_id, :kind, :key, :door_url, :row_json, 1, :next_retry_at_ms, :last_error, :now, :now)
103
- ON CONFLICT(idempotency_key) DO UPDATE SET
104
- row_json = excluded.row_json, door_url = excluded.door_url, attempts = 1,
105
- next_retry_at_ms = excluded.next_retry_at_ms, last_error = excluded.last_error,
106
- created_at_ms = excluded.created_at_ms, updated_at_ms = excluded.updated_at_ms`)
107
- .run({
108
- id: randomUUID(),
109
- project_id: input.projectId,
110
- kind: input.kind,
111
- key,
112
- door_url: input.doorUrl,
113
- row_json: JSON.stringify({ ...input.row, idempotencyKey: key }),
114
- next_retry_at_ms: now + retryDelayMs(1),
115
- last_error: input.error,
116
- now,
117
- });
118
- return outboxOf(store.db.prepare("SELECT * FROM record_outbox WHERE idempotency_key = ?").get(key));
119
- }
120
- export function keepInStore(store, input) {
121
- const now = Date.now();
122
- const key = idempotencyKeyOf(input.projectId ?? input.root, input.kind, input.row.anchor);
123
- store.db
124
- .prepare(`INSERT INTO record_store (id, project_id, repository_root, kind, idempotency_key, row_json, created_at_ms, updated_at_ms)
125
- VALUES (:id, :project_id, :root, :kind, :key, :row_json, :now, :now)
126
- ON CONFLICT(idempotency_key) DO UPDATE SET row_json = excluded.row_json, created_at_ms = excluded.created_at_ms,
127
- updated_at_ms = excluded.updated_at_ms`)
128
- .run({
129
- id: randomUUID(),
130
- project_id: input.projectId,
131
- root: input.root,
132
- kind: input.kind,
133
- key,
134
- row_json: JSON.stringify({ ...input.row, idempotencyKey: key }),
135
- now,
136
- });
137
- return storedOf(store.db.prepare("SELECT * FROM record_store WHERE idempotency_key = ?").get(key));
138
- }
139
- export function listOutbox(store, projectId) {
140
- const rows = projectId
141
- ? store.db.prepare("SELECT * FROM record_outbox WHERE project_id = ? ORDER BY created_at_ms ASC, rowid ASC").all(projectId)
142
- : store.db.prepare("SELECT * FROM record_outbox ORDER BY created_at_ms ASC, rowid ASC").all();
143
- return rows.map(outboxOf);
144
- }
145
- export function listStored(store, filter = {}) {
146
- const rows = store.db.prepare("SELECT * FROM record_store ORDER BY created_at_ms ASC, rowid ASC").all();
147
- return rows
148
- .map(storedOf)
149
- .filter((row) => (!filter.projectId || row.projectId === filter.projectId) && (!filter.root || row.repositoryRoot === filter.root));
150
- }
151
- export async function emitRecords(store, resolution, kind, rows) {
152
- if (resolution.kind === "store") {
153
- const kept = rows.map((row) => keepInStore(store, { projectId: resolution.projectId, root: resolution.root, kind, row }));
154
- return { rung: 3, projectId: resolution.projectId, keys: kept.map((row) => row.idempotencyKey), reason: resolution.reason };
155
- }
156
- const { projectId, doorUrl } = resolution;
157
- const parkedDoor = resolution.isPlatformDefault ? "" : doorUrl;
158
- const keyed = rows.map((row) => ({ ...row, idempotencyKey: idempotencyKeyOf(projectId, kind, row.anchor) }));
159
- const keys = keyed.map((row) => row.idempotencyKey);
160
- try {
161
- const result = await postRecords(doorUrl, projectId, kind, keyed);
162
- return { rung: 1, projectId, keys, ...result };
163
- }
164
- catch (error) {
165
- const message = error instanceof Error ? error.message : String(error);
166
- for (const row of rows)
167
- enqueueOutbox(store, { projectId, kind, doorUrl: parkedDoor, row, error: message });
168
- return { rung: 2, projectId, keys, error: message };
169
- }
170
- }
171
- export async function drainOutbox(store, globals, options = {}) {
172
- const now = Date.now();
173
- let rows = listOutbox(store);
174
- if (options.dueOnly)
175
- rows = rows.filter((row) => row.nextRetryAtMs === null || row.nextRetryAtMs <= now);
176
- if (options.limit !== undefined)
177
- rows = rows.slice(0, options.limit);
178
- let sent = 0;
179
- let kept = 0;
180
- let lastError = null;
181
- for (const row of rows) {
182
- try {
183
- await postRecords(row.doorUrl || resolveNuStackUrl(globals), row.projectId, row.kind, [row.row]);
184
- store.db.prepare("DELETE FROM record_outbox WHERE id = ?").run(row.id);
185
- sent += 1;
186
- }
187
- catch (error) {
188
- lastError = error instanceof Error ? error.message : String(error);
189
- store.db
190
- .prepare(`UPDATE record_outbox SET attempts = attempts + 1, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ? WHERE id = ?`)
191
- .run(Date.now() + retryDelayMs(row.attempts + 1), lastError, Date.now(), row.id);
192
- kept += 1;
193
- }
194
- }
195
- return { sent, kept, lastError };
196
- }
197
- export async function drainStore(store, globals) {
198
- let sent = 0;
199
- let kept = 0;
200
- let lastError = null;
201
- for (const row of listStored(store)) {
202
- if (!existsSync(row.repositoryRoot)) {
203
- kept += 1;
204
- continue;
205
- }
206
- const door = await resolveDoor(row.repositoryRoot, globals);
207
- if (door.kind !== "door") {
208
- kept += 1;
209
- continue;
210
- }
211
- const keyed = { ...row.row, idempotencyKey: idempotencyKeyOf(door.projectId, row.kind, row.row.anchor) };
212
- try {
213
- await postRecords(door.doorUrl, door.projectId, row.kind, [keyed]);
214
- store.db.prepare("DELETE FROM record_store WHERE id = ?").run(row.id);
215
- sent += 1;
216
- }
217
- catch (error) {
218
- lastError = error instanceof Error ? error.message : String(error);
219
- kept += 1;
220
- }
67
+ const code = typeof body?.error?.code === "string" ? body.error.code : null;
68
+ const detail = typeof body?.error?.message === "string" ? body.error.message : `HTTP ${response.status}`;
69
+ return { ok: false, status: response.status, code, message: `${code ?? `HTTP ${response.status}`}: ${detail}` };
221
70
  }
222
- return { sent, kept, lastError };
71
+ return { ok: true, landed: body?.data?.landed ?? 0, skipped: body?.data?.skipped ?? 0 };
223
72
  }
@@ -0,0 +1,219 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, utimesSync, writeSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { ensureAccessToken } from "../auth_grant.js";
6
+ import { recordsDrainLockPath } from "../local_store/paths.js";
7
+ import { resolveNuStackUrl } from "../nustack_client.js";
8
+ import { RECORDS_BATCH_CAP, postRecords, resolveDoor, retryDelayMs } from "./door.js";
9
+ import { DEVICE_TOKEN_VARIABLE, DRAIN_CHILD_VARIABLE, NO_DETACHED_DRAIN_VARIABLE, isFlagSet, readVariable } from "./environment.js";
10
+ import { envelopeOfStored } from "./recordInput.js";
11
+ import { validateEnvelope } from "./recordValidator.js";
12
+ import { OBLIGATION_AGENT_OWNED, markBackoff, markDeferred, markRefused, markSynced, pruneSynced, rekeyRecord, selectDueRecords, } from "./recordsTable.js";
13
+ export const DRAIN_LOCK_STALE_MS = 120_000;
14
+ const MAX_PASSES = 50;
15
+ const DEVICE_PROJECTION_FIELDS = ["origin", "type", "subject", "state", "outcome", "ownerAgentDefinitionId"];
16
+ function payloadToSend(row) {
17
+ const payload = JSON.parse(row.payloadJson);
18
+ if (row.placement !== "device" || row.kind !== "obligation")
19
+ return payload;
20
+ const projection = {};
21
+ for (const field of DEVICE_PROJECTION_FIELDS)
22
+ if (payload[field] !== undefined)
23
+ projection[field] = payload[field];
24
+ return projection;
25
+ }
26
+ async function targetOf(store, row, globals, doors) {
27
+ if (row.doorUrl !== null && row.projectId !== null)
28
+ return { row, doorUrl: row.doorUrl, projectId: row.projectId };
29
+ if (row.repositoryRoot !== null && existsSync(row.repositoryRoot)) {
30
+ let door = doors.get(row.repositoryRoot);
31
+ if (!door) {
32
+ door = await resolveDoor(row.repositoryRoot, globals);
33
+ doors.set(row.repositoryRoot, door);
34
+ }
35
+ if (door.kind !== "door")
36
+ return null;
37
+ if (row.projectId !== null)
38
+ return { row, doorUrl: door.doorUrl, projectId: row.projectId };
39
+ const rekeyed = rekeyRecord(store, row, door.projectId);
40
+ return rekeyed ? { row: rekeyed, doorUrl: door.doorUrl, projectId: door.projectId } : "gone";
41
+ }
42
+ if (row.projectId !== null)
43
+ return { row, doorUrl: resolveNuStackUrl(globals), projectId: row.projectId };
44
+ return null;
45
+ }
46
+ function isRowRefusal(outcome) {
47
+ return outcome.status === 400 || (outcome.status === 403 && outcome.code === OBLIGATION_AGENT_OWNED);
48
+ }
49
+ async function drainPass(store, globals, result, shouldIgnoreBackoff) {
50
+ const now = Date.now();
51
+ const deviceToken = readVariable(DEVICE_TOKEN_VARIABLE);
52
+ const due = selectDueRecords(store, shouldIgnoreBackoff ? Number.MAX_SAFE_INTEGER : now, deviceToken !== null);
53
+ if (due.length === 0)
54
+ return 0;
55
+ const doors = new Map();
56
+ const groups = new Map();
57
+ for (const selected of due) {
58
+ const target = await targetOf(store, selected, globals, doors);
59
+ if (target === "gone")
60
+ continue;
61
+ if (target === null) {
62
+ markDeferred(store, selected, now + retryDelayMs(Math.max(1, selected.attempts)));
63
+ continue;
64
+ }
65
+ const envelope = envelopeOfStored(target.row, target.projectId, payloadToSend(target.row));
66
+ const verdict = validateEnvelope(envelope);
67
+ if (!verdict.isValid) {
68
+ const error = `VALIDATION_FAILED: ${verdict.errors[0] ?? "the row fails the records schema"}`;
69
+ if (markRefused(store, target.row, error)) {
70
+ result.refused += 1;
71
+ result.lastError = error;
72
+ }
73
+ continue;
74
+ }
75
+ const group = `${target.row.credential ?? ""}\u0000${target.doorUrl}\u0000${target.projectId}\u0000${target.row.kind}`;
76
+ const list = groups.get(group) ?? [];
77
+ list.push({ row: target.row, envelope, doorUrl: target.doorUrl, projectId: target.projectId });
78
+ groups.set(group, list);
79
+ }
80
+ let personToken;
81
+ for (const list of groups.values()) {
82
+ const first = list[0];
83
+ let bearer;
84
+ if (first.row.credential === "device")
85
+ bearer = deviceToken;
86
+ else {
87
+ if (personToken === undefined)
88
+ personToken = (await ensureAccessToken().catch(() => null))?.accessToken ?? null;
89
+ bearer = personToken;
90
+ }
91
+ for (let start = 0; start < list.length; start += RECORDS_BATCH_CAP) {
92
+ const batch = list.slice(start, start + RECORDS_BATCH_CAP);
93
+ if (bearer === null) {
94
+ backoffAll(store, batch, "this device is signed out", result);
95
+ continue;
96
+ }
97
+ await sendBatch(store, batch, first.row.kind, bearer, result);
98
+ }
99
+ }
100
+ return due.length;
101
+ }
102
+ function backoffAll(store, batch, error, result) {
103
+ const now = Date.now();
104
+ for (const item of batch)
105
+ markBackoff(store, item.row, error, now + retryDelayMs(item.row.attempts + 1));
106
+ result.lastError = error;
107
+ }
108
+ async function sendBatch(store, batch, kind, bearer, result) {
109
+ const { doorUrl, projectId } = batch[0];
110
+ const outcome = await postRecords(doorUrl, projectId, { kind, rows: batch.map((item) => item.envelope) }, bearer);
111
+ if (outcome.ok) {
112
+ for (const item of batch)
113
+ markSynced(store, item.row);
114
+ result.sent += batch.length;
115
+ return;
116
+ }
117
+ if (!isRowRefusal(outcome)) {
118
+ backoffAll(store, batch, outcome.message, result);
119
+ return;
120
+ }
121
+ if (batch.length === 1) {
122
+ if (markRefused(store, batch[0].row, outcome.message))
123
+ result.refused += 1;
124
+ result.lastError = outcome.message;
125
+ return;
126
+ }
127
+ for (const item of batch) {
128
+ const alone = await postRecords(doorUrl, projectId, { kind, rows: [item.envelope] }, bearer);
129
+ if (alone.ok) {
130
+ markSynced(store, item.row);
131
+ result.sent += 1;
132
+ }
133
+ else if (isRowRefusal(alone)) {
134
+ if (markRefused(store, item.row, alone.message))
135
+ result.refused += 1;
136
+ result.lastError = alone.message;
137
+ }
138
+ else
139
+ backoffAll(store, [item], alone.message, result);
140
+ }
141
+ }
142
+ export async function drainRecords(store, globals, options = {}) {
143
+ const result = { sent: 0, refused: 0, lastError: null };
144
+ pruneSynced(store, Date.now());
145
+ for (let pass = 0; pass < MAX_PASSES; pass += 1) {
146
+ options.onPass?.();
147
+ if ((await drainPass(store, globals, result, pass === 0 && options.isForeground === true)) === 0)
148
+ break;
149
+ }
150
+ return result;
151
+ }
152
+ function tryCreateLock(lockPath) {
153
+ try {
154
+ const fd = openSync(lockPath, "wx", 0o600);
155
+ try {
156
+ writeSync(fd, String(process.pid));
157
+ }
158
+ finally {
159
+ closeSync(fd);
160
+ }
161
+ return true;
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
167
+ export function acquireDrainLock(lockPath = recordsDrainLockPath()) {
168
+ try {
169
+ mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ if (tryCreateLock(lockPath))
175
+ return true;
176
+ try {
177
+ if (Date.now() - statSync(lockPath).mtimeMs < DRAIN_LOCK_STALE_MS)
178
+ return false;
179
+ unlinkSync(lockPath);
180
+ }
181
+ catch {
182
+ }
183
+ return tryCreateLock(lockPath);
184
+ }
185
+ export function refreshDrainLock(lockPath = recordsDrainLockPath()) {
186
+ try {
187
+ const now = new Date();
188
+ utimesSync(lockPath, now, now);
189
+ }
190
+ catch {
191
+ }
192
+ }
193
+ export function releaseDrainLock(lockPath = recordsDrainLockPath()) {
194
+ try {
195
+ if (readFileSync(lockPath, "utf8") === String(process.pid))
196
+ unlinkSync(lockPath);
197
+ }
198
+ catch {
199
+ }
200
+ }
201
+ function cliEntryPath() {
202
+ return fileURLToPath(new URL("../../index.js", import.meta.url));
203
+ }
204
+ export function startDetachedDrain(options = {}) {
205
+ try {
206
+ if (isFlagSet(NO_DETACHED_DRAIN_VARIABLE) || isFlagSet(DRAIN_CHILD_VARIABLE))
207
+ return;
208
+ const args = [cliEntryPath(), ...(options.nustackUrl ? ["--nustack-url", options.nustackUrl] : []), "records", "sync", "--quiet"];
209
+ const child = spawn(process.execPath, args, {
210
+ detached: true,
211
+ stdio: "ignore",
212
+ env: { ...process.env, [DRAIN_CHILD_VARIABLE]: "1" },
213
+ });
214
+ child.on("error", () => { });
215
+ child.unref();
216
+ }
217
+ catch {
218
+ }
219
+ }
@@ -0,0 +1,25 @@
1
+ export const SESSION_ID_VARIABLE = "NUSTACK_SESSION_ID";
2
+ export const AGENT_DEFINITION_ID_VARIABLE = "NUSTACK_AGENT_DEFINITION_ID";
3
+ export const DRAIN_CHILD_VARIABLE = "NUSTACK_DRAIN_CHILD";
4
+ export const NO_DETACHED_DRAIN_VARIABLE = "NUSTACK_NO_DETACHED_DRAIN";
5
+ export const RECORD_CREDENTIAL_VARIABLE = "NUSTACK_RECORD_CREDENTIAL";
6
+ export const DEVICE_TOKEN_VARIABLE = "NUSTACK_DEVICE_TOKEN";
7
+ export const RECORDS_ENVIRONMENT_VARIABLES = [
8
+ SESSION_ID_VARIABLE,
9
+ AGENT_DEFINITION_ID_VARIABLE,
10
+ DRAIN_CHILD_VARIABLE,
11
+ NO_DETACHED_DRAIN_VARIABLE,
12
+ RECORD_CREDENTIAL_VARIABLE,
13
+ DEVICE_TOKEN_VARIABLE,
14
+ ];
15
+ export function readVariable(name, env = process.env) {
16
+ const value = env[name]?.trim();
17
+ return value ? value : null;
18
+ }
19
+ export function isFlagSet(name, env = process.env) {
20
+ const value = readVariable(name, env);
21
+ return value !== null && value !== "0" && value.toLowerCase() !== "false";
22
+ }
23
+ export function recordCredentialOf(env = process.env) {
24
+ return readVariable(RECORD_CREDENTIAL_VARIABLE, env) === "device" ? "device" : null;
25
+ }
@@ -0,0 +1,33 @@
1
+ const field = (payload, name) => String(payload[name]);
2
+ export function naturalKeyOf(kind, payload) {
3
+ switch (kind) {
4
+ case "obligation":
5
+ return `${field(payload, "origin")}#${field(payload, "type")}:${field(payload, "subject")}`;
6
+ case "gate_event":
7
+ return `event:${field(payload, "eventId")}`;
8
+ case "changelog":
9
+ return field(payload, "origin");
10
+ case "memory":
11
+ return `path:${field(payload, "path")}`;
12
+ case "backlog":
13
+ return field(payload, "anchor");
14
+ case "proposal":
15
+ return `${field(payload, "origin")}#${field(payload, "targetKind")}:${field(payload, "targetPath")}`;
16
+ case "triage":
17
+ return `${field(payload, "tracker")}:${field(payload, "itemRef")}`;
18
+ }
19
+ throw new Error(`unknown record kind: ${String(kind)}`);
20
+ }
21
+ export function idempotencyKeyOf(projectId, kind, naturalKey) {
22
+ return `${projectId}:${kind}:${naturalKey}`;
23
+ }
24
+ export function splitObligationKey(naturalKey) {
25
+ const hash = naturalKey.indexOf("#");
26
+ if (hash <= 0)
27
+ return null;
28
+ const rest = naturalKey.slice(hash + 1);
29
+ const colon = rest.indexOf(":");
30
+ if (colon <= 0 || colon === rest.length - 1)
31
+ return null;
32
+ return { origin: naturalKey.slice(0, hash), type: rest.slice(0, colon), subject: rest.slice(colon + 1) };
33
+ }