@nurix/nustack 0.18.1 → 0.19.1

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.
@@ -8,6 +8,7 @@ import { readManagedValues } from "../lib/env_file.js";
8
8
  import { readProjectManifests, diagnoseManifests } from "../lib/onboarding/manifest_writer.js";
9
9
  import { fetchToolkitProfile, ToolkitProfileUnavailableError } from "../lib/etna/profile.js";
10
10
  import { toolkitCompatibility } from "../lib/etna/reconcile.js";
11
+ import { readMountStateSync } from "../lib/harness/mount.js";
11
12
  import { openLocalStore } from "../lib/local_store/database.js";
12
13
  import { readDeviceState } from "../lib/local_store/device_state.js";
13
14
  import { getBindingByRepositoryRoot, listActiveBindings } from "../lib/local_store/bindings.js";
@@ -204,7 +205,7 @@ function addBindingRows(add, store, binding, cloud) {
204
205
  else
205
206
  add("sync", "Pending inbound", "warn", `${pending.length} pending (${technical.length} waiting, ${decisions.length} need a decision)`);
206
207
  }
207
- const compatibility = toolkitCompatibility(binding, cloud);
208
+ const compatibility = toolkitCompatibility(readMountStateSync(binding.repositoryRoot), cloud);
208
209
  if (compatibility.blocksWrites)
209
210
  add("toolkit", "Compatibility gate", "error", `incompatible — ${compatibility.reason}; projection and sync writes are blocked`);
210
211
  else
@@ -0,0 +1,90 @@
1
+ import path from "node:path";
2
+ import { getProject, resolveNuStackUrl } from "../lib/nustack_client.js";
3
+ import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
4
+ import { resolveProjectId } from "../lib/project_context.js";
5
+ import { resolveRepository } from "../lib/git-sync/core/repository.js";
6
+ import { mountHarness, readMountState } from "../lib/harness/mount.js";
7
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
8
+ function emitFail(command, json, code, message) {
9
+ if (json)
10
+ printJsonError(command, { code, message });
11
+ else {
12
+ console.error(message);
13
+ process.exitCode = 1;
14
+ }
15
+ }
16
+ function rootOf(dir) {
17
+ try {
18
+ return resolveRepository(dir).repositoryRoot;
19
+ }
20
+ catch {
21
+ return dir;
22
+ }
23
+ }
24
+ async function cloudHarness(command, json, root, options, globals) {
25
+ const project = await resolveProjectId(root, options);
26
+ if (!project.ok) {
27
+ emitFail(command, json, "PROJECT_CONTEXT_MISSING", project.message);
28
+ return null;
29
+ }
30
+ const baseUrl = resolveNuStackUrl(globals);
31
+ const auth = await ensureAuthWithRecovery(baseUrl, json);
32
+ if (!auth) {
33
+ const failure = lastAuthFailure();
34
+ emitFail(command, json, failure.code, failure.message);
35
+ return null;
36
+ }
37
+ const summary = await getProject(baseUrl, auth.accessToken, project.projectId);
38
+ return { projectId: project.projectId, harness: summary.harness ?? null };
39
+ }
40
+ export async function runHarnessMount(target, options, globals) {
41
+ const json = options.json === true;
42
+ const root = rootOf(path.resolve(target ?? process.cwd()));
43
+ let manifestUrl = options.manifest ?? null;
44
+ let projectId = null;
45
+ if (!manifestUrl) {
46
+ const cloud = await cloudHarness("harness mount", json, root, options, globals);
47
+ if (!cloud)
48
+ return;
49
+ projectId = cloud.projectId;
50
+ manifestUrl = cloud.harness?.manifestUrl ?? null;
51
+ if (!manifestUrl) {
52
+ emitFail("harness mount", json, "HARNESS_UNAVAILABLE", `Project ${cloud.projectId} resolves no harness version with an uploaded manifest.`);
53
+ return;
54
+ }
55
+ }
56
+ let mounted;
57
+ try {
58
+ mounted = await mountHarness(root, manifestUrl);
59
+ }
60
+ catch (error) {
61
+ emitFail("harness mount", json, "HARNESS_MOUNT_FAILED", `The harness mount failed: ${error instanceof Error ? error.message : String(error)}`);
62
+ return;
63
+ }
64
+ if (json) {
65
+ printJsonSuccess("harness mount", { projectId, root, manifestUrl, ...mounted });
66
+ return;
67
+ }
68
+ console.log(`Mounted harness ${mounted.version} into ${root} (${mounted.written} written, ${mounted.kept} unchanged, digest ${mounted.digest.slice(0, 12)})`);
69
+ }
70
+ export async function runHarnessStatus(options, globals) {
71
+ const json = options.json === true;
72
+ const root = rootOf(process.cwd());
73
+ const cloud = await cloudHarness("harness status", json, root, options, globals);
74
+ if (!cloud)
75
+ return;
76
+ const mounted = await readMountState(root);
77
+ const pinned = cloud.harness?.version ?? null;
78
+ const inStep = pinned === null || mounted?.version === pinned;
79
+ if (json) {
80
+ printJsonSuccess("harness status", { projectId: cloud.projectId, mounted, cloud: cloud.harness, inStep });
81
+ }
82
+ else {
83
+ console.log(`Harness for Project ${cloud.projectId}`);
84
+ console.log(` mounted: ${mounted ? `${mounted.version} (digest ${mounted.digest.slice(0, 12)})` : "(nothing mounted)"}`);
85
+ console.log(` cloud pin: ${cloud.harness ? `${cloud.harness.slug} ${cloud.harness.version}` : "(none)"}`);
86
+ console.log(` verdict: ${inStep ? "in step" : "DIFFERS — run `nustack harness mount`"}`);
87
+ }
88
+ if (!inStep)
89
+ process.exitCode = 1;
90
+ }
@@ -16,6 +16,7 @@ import { resolveRepository } from "../lib/git-sync/core/repository.js";
16
16
  import { readProjectManifests } from "../lib/onboarding/manifest_writer.js";
17
17
  import { hasManifest, scaffoldManifest } from "../lib/manifest.js";
18
18
  import { toolkitCompatibility } from "../lib/etna/reconcile.js";
19
+ import { readMountStateSync } from "../lib/harness/mount.js";
19
20
  import { attachExistingGit } from "../lib/onboarding/attach_existing_git.js";
20
21
  import { webFirstAttach } from "../lib/onboarding/web_first_attach.js";
21
22
  import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
@@ -183,6 +184,7 @@ export async function runProjectStatus(options, globals) {
183
184
  rendererVersion: cloudProject.toolkit.rendererVersion,
184
185
  placementMapVersion: cloudProject.toolkit.placementMapVersion,
185
186
  catalogVersionId: null,
187
+ harnessVersion: cloudProject.harness?.version ?? null,
186
188
  };
187
189
  }
188
190
  }
@@ -190,7 +192,7 @@ export async function runProjectStatus(options, globals) {
190
192
  cloudProject = null;
191
193
  }
192
194
  }
193
- const compatibility = toolkitCompatibility(binding ?? { etnaProfileJson: null }, cloud);
195
+ const compatibility = toolkitCompatibility(readMountStateSync(binding?.repositoryRoot ?? cwd), cloud);
194
196
  if (json) {
195
197
  printJsonSuccess("project status", {
196
198
  projectId: project.projectId,
@@ -0,0 +1,150 @@
1
+ import { existsSync } from "node:fs";
2
+ import { openLocalStore } from "../lib/local_store/database.js";
3
+ import { stateDbPath } from "../lib/local_store/paths.js";
4
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
5
+ import { RECORDS_BATCH_CAP, drainOutbox, drainStore, emitRecords, isRecordKind, listOutbox, listStored, resolveDoor, } from "../lib/records/door.js";
6
+ async function readStdin() {
7
+ if (process.stdin.isTTY)
8
+ return "";
9
+ const chunks = [];
10
+ for await (const chunk of process.stdin)
11
+ chunks.push(chunk);
12
+ return Buffer.concat(chunks).toString("utf8");
13
+ }
14
+ function parseRows(raw) {
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(raw);
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ const list = Array.isArray(parsed) ? parsed : [parsed];
23
+ const rows = list.filter((row) => !!row && typeof row === "object" && typeof row.anchor === "string" && row.anchor.trim() !== "");
24
+ return rows.length === list.length ? rows : null;
25
+ }
26
+ function emitLine(outcome) {
27
+ if (outcome.rung === 1)
28
+ return `records emit: landed ${outcome.landed} on NuStack (${outcome.projectId})\n`;
29
+ if (outcome.rung === 2)
30
+ return `records emit: the door refused (${outcome.error}); ${outcome.keys.length} row(s) wait in the outbox\n`;
31
+ return `records emit: ${outcome.reason}; ${outcome.keys.length} row(s) kept in the local store\n`;
32
+ }
33
+ export async function runRecordsEmit(options, globals) {
34
+ const json = options.json === true;
35
+ const refuse = (message) => {
36
+ if (json)
37
+ printJsonError("records emit", { code: "VALIDATION_FAILED", message });
38
+ else {
39
+ process.stderr.write(`records emit: ${message}\n`);
40
+ process.exitCode = 1;
41
+ }
42
+ };
43
+ if (!isRecordKind(options.kind))
44
+ return refuse("--kind must be backlog, backlog.remove, bookkeeping or gate_event");
45
+ const rows = parseRows(options.row ?? (await readStdin()));
46
+ if (!rows || rows.length === 0)
47
+ return refuse("the rows must be JSON — one object or an array — each with an anchor");
48
+ const root = options.root ?? process.cwd();
49
+ const resolution = await resolveDoor(root, globals, options.project);
50
+ const store = await openLocalStore();
51
+ try {
52
+ const outcomes = [];
53
+ for (let start = 0; start < rows.length; start += RECORDS_BATCH_CAP) {
54
+ outcomes.push(await emitRecords(store, resolution, options.kind, rows.slice(start, start + RECORDS_BATCH_CAP)));
55
+ }
56
+ if (outcomes.every((outcome) => outcome.rung === 1))
57
+ await drainOutbox(store, globals, { dueOnly: true });
58
+ const last = outcomes[outcomes.length - 1];
59
+ const keys = outcomes.flatMap((outcome) => outcome.keys);
60
+ if (json)
61
+ printJsonSuccess("records emit", { ...last, keys });
62
+ else
63
+ process.stdout.write(emitLine({ ...last, keys }));
64
+ }
65
+ finally {
66
+ store.close();
67
+ }
68
+ }
69
+ export async function runRecordsSync(options, globals) {
70
+ const store = await openLocalStore();
71
+ try {
72
+ const outbox = await drainOutbox(store, globals);
73
+ const stored = await drainStore(store, globals);
74
+ const result = {
75
+ sent: outbox.sent + stored.sent,
76
+ waiting: listOutbox(store).length,
77
+ stored: listStored(store).length,
78
+ lastError: outbox.lastError ?? stored.lastError,
79
+ };
80
+ if (options.json)
81
+ printJsonSuccess("records sync", result);
82
+ else
83
+ process.stdout.write(`records sync: sent ${result.sent}; ${result.waiting} wait in the outbox, ${result.stored} kept locally\n`);
84
+ }
85
+ finally {
86
+ store.close();
87
+ }
88
+ }
89
+ export async function runRecordsList(options) {
90
+ const store = await openLocalStore();
91
+ try {
92
+ const outbox = listOutbox(store, options.project);
93
+ const stored = listStored(store, options.project ? { projectId: options.project } : {});
94
+ const newest = [...outbox].sort((a, b) => b.createdAtMs - a.createdAtMs).find((row) => row.lastError !== null);
95
+ const data = {
96
+ outboxCount: outbox.length,
97
+ storeCount: stored.length,
98
+ lastError: newest?.lastError ?? null,
99
+ outbox,
100
+ store: stored,
101
+ };
102
+ if (options.json)
103
+ printJsonSuccess("records list", data);
104
+ else {
105
+ process.stdout.write(`records list: ${data.outboxCount} in the outbox, ${data.storeCount} kept locally\n`);
106
+ if (data.lastError)
107
+ process.stdout.write(` last error: ${data.lastError}\n`);
108
+ }
109
+ }
110
+ finally {
111
+ store.close();
112
+ }
113
+ }
114
+ export async function drainBeforeCommand(globals) {
115
+ if (!existsSync(stateDbPath()))
116
+ return;
117
+ try {
118
+ const store = await openLocalStore();
119
+ try {
120
+ await drainOutbox(store, globals, { dueOnly: true, limit: 20 });
121
+ }
122
+ finally {
123
+ store.close();
124
+ }
125
+ }
126
+ catch {
127
+ }
128
+ }
129
+ export function registerRecordsCommands(recordsCommand, getGlobalOptions) {
130
+ recordsCommand
131
+ .command("emit")
132
+ .description("Land records on NuStack, else park them in the outbox, else keep them locally")
133
+ .option("--kind <kind>", "backlog, backlog.remove, bookkeeping or gate_event")
134
+ .option("--row <json>", "the row, or an array of rows, as JSON (default: read from stdin)")
135
+ .option("--root <dir>", "the folder the records are about (default: the current directory)")
136
+ .option("--project <id>", "the Project, when the folder carries no binding marker")
137
+ .option("--json", "emit the versioned machine envelope")
138
+ .action(async (options) => runRecordsEmit(options, getGlobalOptions()));
139
+ recordsCommand
140
+ .command("sync")
141
+ .description("Send every outbox row, and stored rows whose folder now has a door")
142
+ .option("--json", "emit the versioned machine envelope")
143
+ .action(async (options) => runRecordsSync(options, getGlobalOptions()));
144
+ recordsCommand
145
+ .command("list")
146
+ .description("Show the rows that have not reached NuStack: the outbox and the local store")
147
+ .option("--project <id>", "only this Project's rows")
148
+ .option("--json", "emit the versioned machine envelope")
149
+ .action(async (options) => runRecordsList(options));
150
+ }
@@ -9,6 +9,7 @@ import { resolveRepository } from "../lib/git-sync/core/repository.js";
9
9
  import { readProjectManifests, diagnoseManifests } from "../lib/onboarding/manifest_writer.js";
10
10
  import { fetchToolkitProfile, ToolkitProfileUnavailableError } from "../lib/etna/profile.js";
11
11
  import { buildReconcilePlan, applyReconcile, toolkitCompatibility } from "../lib/etna/reconcile.js";
12
+ import { readMountStateSync } from "../lib/harness/mount.js";
12
13
  import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
13
14
  function emitFail(command, json, code, message) {
14
15
  if (json)
@@ -59,8 +60,7 @@ export async function runToolkitStatus(options, globals) {
59
60
  }
60
61
  const manifests = await readProjectManifests(ctx.root);
61
62
  const diagnosis = diagnoseManifests({ name: cloud.name, version: cloud.version }, manifests.projectManifest?.toolkit ?? null, manifests.serviceToolkit);
62
- const binding = await withStore((store) => getBindingByRepositoryRoot(store, ctx.root));
63
- const compatibility = toolkitCompatibility(binding ?? { etnaProfileJson: null }, cloud);
63
+ const compatibility = toolkitCompatibility(readMountStateSync(ctx.root), cloud);
64
64
  if (json) {
65
65
  printJsonSuccess("toolkit status", {
66
66
  projectId: ctx.projectId,
package/dist/index.js CHANGED
@@ -24,8 +24,10 @@ import { runOrganizationList, runOrganizationCreate, runOrganizationUse } from "
24
24
  import { runDocsList, runDocsShow, runDocsStatus } from "./commands/docs.js";
25
25
  import { runHandoffList, runHandoffShow } from "./commands/handoff.js";
26
26
  import { runToolkitStatus, runToolkitReconcile } from "./commands/toolkit.js";
27
+ import { runHarnessMount, runHarnessStatus } from "./commands/harness.js";
27
28
  import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
28
29
  import { registerReviewCommands } from "./commands/review.js";
30
+ import { drainBeforeCommand, registerRecordsCommands } from "./commands/records.js";
29
31
  import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
30
32
  import { printJsonError } from "./lib/json_output.js";
31
33
  import { setCliVersion } from "./lib/auth_grant.js";
@@ -216,6 +218,11 @@ const project = program.command("project").description("Work with NuStack Projec
216
218
  registerProjectCommands(project, () => program.opts());
217
219
  registerProjectSyncCommands(project, () => program.opts());
218
220
  registerReviewCommands(program.command("review").description("Read and mirror NuStack Review items"), () => program.opts());
221
+ registerRecordsCommands(program.command("records").description("Emit, send and list harness records"), () => program.opts());
222
+ program.hook("preAction", async (_program, action) => {
223
+ if (action.parent?.name() !== "records")
224
+ await drainBeforeCommand(program.opts());
225
+ });
219
226
  const docs = program.command("docs").description("Work with a Project's Documents");
220
227
  docs
221
228
  .command("list")
@@ -275,6 +282,20 @@ sessions
275
282
  .option("--force", "also reclaim an unmerged or dirty session — never one whose engine is running")
276
283
  .option("--json", "emit the versioned machine envelope")
277
284
  .action(async (options) => runSessionsPrune(options));
285
+ const harness = program.command("harness").description("Mount the Project's harness and compare it with the cloud pin");
286
+ harness
287
+ .command("mount [path]")
288
+ .description("Mount the resolved harness version into a checkout (default: the current repository)")
289
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
290
+ .option("--manifest <url>", "mount this manifest instead of the one the Project resolves")
291
+ .option("--json", "emit the versioned machine envelope")
292
+ .action(async (target, options) => runHarnessMount(target, options, program.opts()));
293
+ harness
294
+ .command("status")
295
+ .description("Print the mounted harness against the cloud pin; exits 1 when they differ")
296
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
297
+ .option("--json", "emit the versioned machine envelope")
298
+ .action(async (options) => runHarnessStatus(options, program.opts()));
278
299
  const toolkit = program.command("toolkit").description("Inspect and reconcile the Project's toolkit pin");
279
300
  toolkit
280
301
  .command("status")
@@ -2,6 +2,7 @@ import { detectPackageManager, installPackage } from "../package_manager.js";
2
2
  import { assertSafePackageName } from "../guards.js";
3
3
  import { updateBindingEtnaProfile } from "../local_store/bindings.js";
4
4
  import { serializeProfileForBinding } from "./profile.js";
5
+ import { mountHarness } from "../harness/mount.js";
5
6
  export function toolkitPackageName(profile) {
6
7
  if (!profile.name.startsWith("@"))
7
8
  return null;
@@ -15,7 +16,17 @@ export function toolkitPackageName(profile) {
15
16
  export async function installToolkit(store, bindingId, profile, cwd, deps = {}) {
16
17
  const packageName = toolkitPackageName(profile);
17
18
  let result;
18
- if (!packageName) {
19
+ const manifestUrl = deps.manifestUrl ?? profile.manifestUrl ?? null;
20
+ if (manifestUrl) {
21
+ try {
22
+ await (deps.mount ?? mountHarness)(cwd, manifestUrl);
23
+ result = { ok: true, installed: true, skipped: false, reason: "mounted" };
24
+ }
25
+ catch (error) {
26
+ result = { ok: false, installed: false, skipped: false, reason: error instanceof Error ? error.message : String(error) };
27
+ }
28
+ }
29
+ else if (!packageName) {
19
30
  result = { ok: true, installed: false, skipped: true, reason: "device_level_toolkit" };
20
31
  }
21
32
  else {
@@ -19,6 +19,8 @@ export async function fetchToolkitProfile(baseUrl, accessToken, projectId) {
19
19
  rendererVersion: typeof toolkit.rendererVersion === "string" ? toolkit.rendererVersion : "",
20
20
  placementMapVersion: typeof toolkit.placementMapVersion === "string" ? toolkit.placementMapVersion : "",
21
21
  catalogVersionId: null,
22
+ harnessVersion: project.harness?.version ?? null,
23
+ manifestUrl: project.harness?.manifestUrl ?? null,
22
24
  };
23
25
  }
24
26
  export function toolkitProfileFromManifest(manifest) {
@@ -19,18 +19,12 @@ export function parseRecordedToolkit(binding) {
19
19
  return null;
20
20
  }
21
21
  }
22
- export function toolkitCompatibility(binding, cloud) {
23
- const recorded = parseRecordedToolkit(binding);
24
- if (!recorded || !cloud)
22
+ export function toolkitCompatibility(mounted, cloud) {
23
+ if (!mounted || !cloud)
25
24
  return { compatible: true, blocksWrites: false, reason: null };
26
- if (recorded.name !== cloud.name) {
27
- return { compatible: false, blocksWrites: true, reason: `toolkit name diverges (local ${recorded.name} vs cloud ${cloud.name})` };
28
- }
29
- if (recorded.version !== cloud.version) {
30
- return { compatible: false, blocksWrites: true, reason: `toolkit version diverges (local ${recorded.version} vs cloud ${cloud.version})` };
31
- }
32
- if (cloud.rendererVersion && recorded.rendererVersion && recorded.rendererVersion !== cloud.rendererVersion) {
33
- return { compatible: false, blocksWrites: true, reason: `renderer version diverges (local ${recorded.rendererVersion} vs cloud ${cloud.rendererVersion})` };
25
+ const pinned = cloud.harnessVersion ?? cloud.version;
26
+ if (mounted.version !== pinned) {
27
+ return { compatible: false, blocksWrites: true, reason: `mounted harness ${mounted.version} differs from the cloud pin ${pinned}` };
34
28
  }
35
29
  return { compatible: true, blocksWrites: false, reason: null };
36
30
  }
@@ -0,0 +1,155 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
7
+ export const MOUNT_STATE_PATH = ".claude/state/mount.json";
8
+ const IGNORE_START = "# etna:mount:start";
9
+ const IGNORE_END = "# etna:mount:end";
10
+ const ETNA_MOUNT_SPECIFIER = "@nurix/etna/mount";
11
+ const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
12
+ export function defaultCacheDir(env = process.env) {
13
+ return env.ETNA_CACHE_DIR?.trim() || path.join(homedir(), ".etna", "cache");
14
+ }
15
+ export function isMountedPath(file) {
16
+ if (file.policy)
17
+ return file.policy === "mount";
18
+ const contract = ["CLAUDE.md", "AGENTS.md", ".claude/harness.json", ".claude/settings.json", ".claude/hooks/mount.mjs", ".codex/hooks.json"];
19
+ if (contract.includes(file.path))
20
+ return false;
21
+ return file.path.startsWith(".claude/") || file.path.startsWith(".agents/") || file.path.startsWith(".codex/");
22
+ }
23
+ async function readIfPresent(file) {
24
+ try {
25
+ return await readFile(file);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export async function readMountState(root) {
32
+ return parseMountState(await readIfPresent(path.join(root, MOUNT_STATE_PATH)));
33
+ }
34
+ export function readMountStateSync(root) {
35
+ try {
36
+ return parseMountState(readFileSync(path.join(root, MOUNT_STATE_PATH)));
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ function parseMountState(raw) {
43
+ if (!raw)
44
+ return null;
45
+ try {
46
+ const parsed = JSON.parse(raw.toString("utf8"));
47
+ if (typeof parsed.version !== "string" || typeof parsed.digest !== "string")
48
+ return null;
49
+ return { version: parsed.version, digest: parsed.digest, mountedAt: typeof parsed.mountedAt === "string" ? parsed.mountedAt : "", ...(typeof parsed.manifestUrl === "string" ? { manifestUrl: parsed.manifestUrl } : {}) };
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ async function fetchBytes(fetchImpl, url) {
56
+ const response = await fetchImpl(url);
57
+ if (!response.ok)
58
+ throw new Error(`${url} answered ${response.status}`);
59
+ return Buffer.from(await response.arrayBuffer());
60
+ }
61
+ function objectUrlOf(manifestUrl, key) {
62
+ return `${new URL(manifestUrl).origin}/${key.replace(/^\/+/, "")}`;
63
+ }
64
+ async function writeAtomic(target, bytes) {
65
+ await mkdir(path.dirname(target), { recursive: true });
66
+ const staging = `${target}.mount-${process.pid}`;
67
+ await writeFile(staging, bytes);
68
+ await rename(staging, target);
69
+ }
70
+ function ignoredNamesOf(files) {
71
+ const names = new Set([".claude/state/"]);
72
+ for (const file of files) {
73
+ const parts = file.path.split("/");
74
+ if (parts[0] === ".agents")
75
+ names.add(".agents/skills/");
76
+ else if (parts[0] === ".codex")
77
+ names.add(parts.length > 2 ? `.codex/${parts[1]}/` : `.codex/${parts[1]}`);
78
+ else if (parts.length > 3)
79
+ names.add(`${parts.slice(0, 3).join("/")}/`);
80
+ else
81
+ names.add(file.path);
82
+ }
83
+ return [...names].sort();
84
+ }
85
+ async function writeExcludeBlock(root, files) {
86
+ let excludePath;
87
+ try {
88
+ excludePath = execFileSync("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
89
+ }
90
+ catch {
91
+ return;
92
+ }
93
+ const absolute = path.isAbsolute(excludePath) ? excludePath : path.join(root, excludePath);
94
+ const held = (await readIfPresent(absolute))?.toString("utf8") ?? "";
95
+ const start = held.indexOf(IGNORE_START);
96
+ const end = held.indexOf(IGNORE_END);
97
+ const outside = start >= 0 && end > start ? held.slice(0, start) + held.slice(end + IGNORE_END.length).replace(/^\n/, "") : held;
98
+ const block = [IGNORE_START, ...ignoredNamesOf(files), IGNORE_END].join("\n");
99
+ await writeAtomic(absolute, `${outside.replace(/\n*$/, outside ? "\n" : "")}${block}\n`);
100
+ }
101
+ export async function mountWithAdapter(root, manifestUrl, options = {}) {
102
+ const fetchImpl = options.fetchImpl ?? fetch;
103
+ const cacheDir = options.cacheDir ?? defaultCacheDir();
104
+ const manifestBytes = await fetchBytes(fetchImpl, manifestUrl);
105
+ const digest = sha256Of(manifestBytes);
106
+ const manifest = JSON.parse(manifestBytes.toString("utf8"));
107
+ if (!manifest || !Array.isArray(manifest.files))
108
+ throw new Error(`the manifest at ${manifestUrl} lists no files`);
109
+ const version = String(manifest.version ?? "");
110
+ const prior = await readMountState(root);
111
+ const mounted = manifest.files.filter(isMountedPath);
112
+ let written = 0;
113
+ let kept = 0;
114
+ for (const file of mounted) {
115
+ if (file.path.split("/").includes("..") || path.isAbsolute(file.path))
116
+ throw new Error(`the manifest names an unsafe path: ${file.path}`);
117
+ const target = path.join(root, file.path);
118
+ const held = await readIfPresent(target);
119
+ if (held && file.sha256 && sha256Of(held) === file.sha256) {
120
+ kept += 1;
121
+ continue;
122
+ }
123
+ const cached = file.sha256 ? await readIfPresent(path.join(cacheDir, "objects", file.sha256)) : null;
124
+ let bytes = cached && sha256Of(cached) === file.sha256 ? cached : null;
125
+ if (!bytes) {
126
+ bytes = await fetchBytes(fetchImpl, objectUrlOf(manifestUrl, file.key));
127
+ if (file.sha256 && sha256Of(bytes) !== file.sha256)
128
+ throw new Error(`the object for ${file.path} does not match its sha256`);
129
+ if (file.sha256)
130
+ await writeAtomic(path.join(cacheDir, "objects", file.sha256), bytes);
131
+ }
132
+ await writeAtomic(target, bytes);
133
+ written += 1;
134
+ }
135
+ await writeExcludeBlock(root, mounted);
136
+ await mkdir(path.join(root, ".claude", "state"), { recursive: true });
137
+ if (!(await readIfPresent(path.join(root, ".claude", "state", ".gitignore"))))
138
+ await writeFile(path.join(root, ".claude", "state", ".gitignore"), "*\n");
139
+ const state = { version, digest, mountedAt: new Date().toISOString(), manifestUrl };
140
+ if (!prior || prior.digest !== digest || prior.version !== version || written > 0)
141
+ await writeAtomic(path.join(root, MOUNT_STATE_PATH), `${JSON.stringify(state, null, 2)}\n`);
142
+ return { version, digest, written, kept, via: "adapter" };
143
+ }
144
+ export async function mountHarness(root, manifestUrl, options = {}) {
145
+ if (options.useEtna !== false) {
146
+ const specifier = ETNA_MOUNT_SPECIFIER;
147
+ const etna = (await import(specifier).catch(() => null));
148
+ if (etna && typeof etna.mount === "function") {
149
+ await etna.mount(root, manifestUrl);
150
+ const state = await readMountState(root);
151
+ return { version: state?.version ?? "", digest: state?.digest ?? "", written: 0, kept: 0, via: "etna" };
152
+ }
153
+ }
154
+ return mountWithAdapter(root, manifestUrl, options);
155
+ }
@@ -205,6 +205,36 @@ const MIGRATION_V7 = {
205
205
  `CREATE INDEX local_bindings_project_idx ON local_bindings (project_id)`,
206
206
  ],
207
207
  };
208
+ const MIGRATION_V8 = {
209
+ version: 8,
210
+ statements: [
211
+ `CREATE TABLE record_outbox (
212
+ id TEXT PRIMARY KEY,
213
+ project_id TEXT NOT NULL,
214
+ kind TEXT NOT NULL CHECK (kind IN ('backlog','backlog.remove','bookkeeping','gate_event')),
215
+ idempotency_key TEXT NOT NULL,
216
+ door_url TEXT NOT NULL,
217
+ row_json TEXT NOT NULL CHECK (json_valid(row_json)),
218
+ attempts INTEGER NOT NULL DEFAULT 0,
219
+ next_retry_at_ms INTEGER,
220
+ last_error TEXT,
221
+ created_at_ms INTEGER NOT NULL,
222
+ updated_at_ms INTEGER NOT NULL
223
+ )`,
224
+ `CREATE UNIQUE INDEX record_outbox_idempotency_key_unique ON record_outbox (idempotency_key)`,
225
+ `CREATE TABLE record_store (
226
+ id TEXT PRIMARY KEY,
227
+ project_id TEXT,
228
+ repository_root TEXT NOT NULL,
229
+ kind TEXT NOT NULL CHECK (kind IN ('backlog','backlog.remove','bookkeeping','gate_event')),
230
+ idempotency_key TEXT NOT NULL,
231
+ row_json TEXT NOT NULL CHECK (json_valid(row_json)),
232
+ created_at_ms INTEGER NOT NULL,
233
+ updated_at_ms INTEGER NOT NULL
234
+ )`,
235
+ `CREATE UNIQUE INDEX record_store_idempotency_key_unique ON record_store (idempotency_key)`,
236
+ ],
237
+ };
208
238
  export const MIGRATIONS = [
209
239
  MIGRATION_V1,
210
240
  MIGRATION_V2,
@@ -213,6 +243,7 @@ export const MIGRATIONS = [
213
243
  MIGRATION_V5,
214
244
  MIGRATION_V6,
215
245
  MIGRATION_V7,
246
+ MIGRATION_V8,
216
247
  ];
217
248
  export const LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
218
249
  export class LocalSchemaTooNewError extends Error {
@@ -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.18.1",
3
+ "version": "0.19.1",
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,6 +21,7 @@
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"