@nurix/nustack 0.23.4 → 0.23.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/recordAliases.js +183 -0
- package/dist/commands/records.js +192 -94
- package/dist/index.js +6 -3
- package/dist/lib/desktop_settings.js +23 -0
- package/dist/lib/local_store/database.js +1 -1
- package/dist/lib/local_store/migrations.js +79 -0
- package/dist/lib/local_store/paths.js +6 -0
- package/dist/lib/records/door.js +23 -174
- package/dist/lib/records/drain.js +219 -0
- package/dist/lib/records/environment.js +25 -0
- package/dist/lib/records/naturalKey.js +33 -0
- package/dist/lib/records/recordInput.js +145 -0
- package/dist/lib/records/recordValidator.js +38 -0
- package/dist/lib/records/recordsTable.js +287 -0
- package/dist/lib/records/refusalLog.js +56 -0
- package/dist/lib/records/secretScan.js +45 -0
- package/package.json +6 -5
|
@@ -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
|
}
|
package/dist/lib/records/door.js
CHANGED
|
@@ -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 = ["
|
|
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
|
|
17
|
-
return
|
|
16
|
+
export function isLegacyKind(value) {
|
|
17
|
+
return typeof value === "string" && Object.hasOwn(LEGACY_KIND_TRANSLATION, value);
|
|
18
18
|
}
|
|
19
|
-
|
|
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
|
|
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,
|
|
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 ${
|
|
54
|
-
body: JSON.stringify(
|
|
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
|
-
|
|
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
|
|
65
|
+
const body = (await response.json().catch(() => null));
|
|
62
66
|
if (!response.ok) {
|
|
63
|
-
const
|
|
64
|
-
|
|
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 {
|
|
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
|
+
}
|