@nurix/nustack 0.19.0 → 0.19.2

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.
@@ -0,0 +1,223 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { ensureAccessToken } from "../auth_grant.js";
6
+ import { resolveNuStackUrl } from "../nustack_client.js";
7
+ import { resolveProjectId } from "../project_context.js";
8
+ export const RECORD_KINDS = ["backlog", "backlog.remove", "bookkeeping", "gate_event"];
9
+ export const RECORDS_BATCH_CAP = 200;
10
+ const REQUEST_TIMEOUT_MS = 10_000;
11
+ const RETRY_BASE_MS = 30_000;
12
+ const RETRY_CEILING_MS = 60 * 60 * 1000;
13
+ export function isRecordKind(value) {
14
+ return typeof value === "string" && RECORD_KINDS.includes(value);
15
+ }
16
+ export function idempotencyKeyOf(project, kind, anchor) {
17
+ return `${project}:${kind}:${anchor}`;
18
+ }
19
+ async function readMountDoor(root) {
20
+ const manifest = path.join(root, ".claude", "harness.json");
21
+ if (!existsSync(manifest))
22
+ return undefined;
23
+ try {
24
+ const parsed = JSON.parse(await readFile(manifest, "utf8"));
25
+ if (!parsed.mount || !("door" in parsed.mount))
26
+ return undefined;
27
+ const door = parsed.mount.door;
28
+ return typeof door === "string" && door.trim() !== "" ? door.trim().replace(/\/+$/, "") : null;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ export async function resolveDoor(root, globals, projectFlag) {
35
+ const project = await resolveProjectId(root, projectFlag ? { project: projectFlag } : {});
36
+ if (!project.ok)
37
+ return { kind: "store", projectId: null, root, reason: "this folder is not bound to a Project" };
38
+ const door = await readMountDoor(root);
39
+ if (door === null)
40
+ return { kind: "store", projectId: project.projectId, root, reason: "the harness manifest configures no door" };
41
+ return { kind: "door", projectId: project.projectId, doorUrl: door ?? resolveNuStackUrl(globals), isPlatformDefault: door === undefined, root };
42
+ }
43
+ export class DoorRefusedError extends Error {
44
+ }
45
+ export async function postRecords(doorUrl, projectId, kind, rows) {
46
+ const auth = await ensureAccessToken(doorUrl).catch(() => null);
47
+ if (!auth)
48
+ throw new DoorRefusedError("this device is signed out");
49
+ let response;
50
+ try {
51
+ response = await fetch(`${doorUrl}/api/v2/projects/${encodeURIComponent(projectId)}/records`, {
52
+ method: "POST",
53
+ headers: { "content-type": "application/json", authorization: `Bearer ${auth.accessToken}` },
54
+ body: JSON.stringify({ kind, rows }),
55
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
56
+ });
57
+ }
58
+ catch (error) {
59
+ throw new DoorRefusedError(`the door did not answer: ${error instanceof Error ? error.message : String(error)}`);
60
+ }
61
+ const payload = (await response.json().catch(() => null));
62
+ 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
+ }
221
+ }
222
+ return { sent, kept, lastError };
223
+ }
@@ -7,6 +7,7 @@ import { FRAME_VERSION, parseInboundFrame, serializeFrame, } from "./frames.js";
7
7
  import { inspectSession, reclaimSession } from "./prune.js";
8
8
  import { sessionRegistryPath, readSessionRegistry, updateSessionRegistry } from "./registry.js";
9
9
  import { createSessionWorktree } from "./worktree.js";
10
+ import { mountHarness } from "../harness/mount.js";
10
11
  function defaultProbeClaude(env) {
11
12
  return new Promise((resolve) => {
12
13
  execFile("claude", ["--version"], { env, timeout: 5_000 }, (error, stdout) => {
@@ -131,6 +132,21 @@ export class SessionSupervisor {
131
132
  return;
132
133
  }
133
134
  }
135
+ async mountInto(sessionId, root, projectId) {
136
+ const lookup = this.options.sessions?.harnessManifestUrl;
137
+ if (!lookup)
138
+ return;
139
+ try {
140
+ const manifestUrl = await lookup.call(this.options.sessions, projectId);
141
+ if (!manifestUrl)
142
+ return;
143
+ await (this.options.mountHarness ?? mountHarness)(root, manifestUrl);
144
+ }
145
+ catch (error) {
146
+ const log = this.options.log ?? ((line) => process.stderr.write(`${line}\n`));
147
+ log(`nustack: harness mount skipped for session ${sessionId} - ${error instanceof Error ? error.message : String(error)}`);
148
+ }
149
+ }
134
150
  async openSession(sessionId, projectPath, permissionMode, isolated, resumeSessionId, commit) {
135
151
  if (this.sessions.has(sessionId))
136
152
  return this.error(sessionId, "SESSION_ALREADY_OPEN", `session ${sessionId} is already open`);
@@ -158,6 +174,8 @@ export class SessionSupervisor {
158
174
  enginePid: process.pid,
159
175
  closedAt: null,
160
176
  };
177
+ if (record.projectId)
178
+ await this.mountInto(sessionId, worktree.worktreePath, record.projectId);
161
179
  const adapterOptions = {
162
180
  cwd: worktree.worktreePath,
163
181
  permissionMode,
@@ -37,6 +37,18 @@ export function makeSessionsClient(baseUrl, accessToken) {
37
37
  async setVendorSessionId(projectId, sessionId, vendorSessionId) {
38
38
  return this.transitionSession(projectId, sessionId, "running", { vendorSessionId });
39
39
  },
40
+ async harnessManifestUrl(projectId) {
41
+ const safeId = assertSafeOpaqueId("The Project id", projectId);
42
+ const response = await globalThis.fetch(`${baseUrl}/api/v2/projects/${encodeURIComponent(safeId)}`, {
43
+ headers,
44
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
45
+ });
46
+ const payload = (await response.json().catch(() => null));
47
+ if (!response.ok)
48
+ throw new Error(`project summary fetch failed: HTTP ${response.status}`);
49
+ const url = payload?.data?.harness?.manifestUrl;
50
+ return typeof url === "string" && url ? url : null;
51
+ },
40
52
  };
41
53
  }
42
54
  export function sessionsClientFromEnv(env = process.env) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -21,13 +21,14 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@clack/prompts": "^0.11.0",
24
+ "@nurix/etna": "0.20.25",
24
25
  "ajv": "^8.17.1",
25
26
  "ajv-formats": "^3.0.1",
26
27
  "commander": "^14.0.0"
27
28
  },
28
29
  "optionalDependencies": {
29
- "@nurix/codegraph": "0.2.1",
30
- "@nurix/logscope": "0.1.0"
30
+ "@nurix/logscope": "0.1.0",
31
+ "@nurix/codegraph": "0.2.1"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@ngneat/falso": "^8.0.2",