@nurix/nustack 0.19.0 → 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.
- package/dist/commands/doctor.js +2 -1
- package/dist/commands/harness.js +90 -0
- package/dist/commands/project.js +3 -1
- package/dist/commands/records.js +150 -0
- package/dist/commands/toolkit.js +2 -2
- package/dist/index.js +21 -0
- package/dist/lib/etna/manager.js +12 -1
- package/dist/lib/etna/profile.js +2 -0
- package/dist/lib/etna/reconcile.js +5 -11
- package/dist/lib/harness/mount.js +155 -0
- package/dist/lib/local_store/migrations.js +31 -0
- package/dist/lib/records/door.js +223 -0
- package/dist/lib/session-engine/supervisor.js +18 -0
- package/dist/lib/sessions/client.js +12 -0
- package/package.json +2 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/project.js
CHANGED
|
@@ -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 ??
|
|
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
|
+
}
|
package/dist/commands/toolkit.js
CHANGED
|
@@ -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
|
|
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")
|
package/dist/lib/etna/manager.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/dist/lib/etna/profile.js
CHANGED
|
@@ -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(
|
|
23
|
-
|
|
24
|
-
if (!recorded || !cloud)
|
|
22
|
+
export function toolkitCompatibility(mounted, cloud) {
|
|
23
|
+
if (!mounted || !cloud)
|
|
25
24
|
return { compatible: true, blocksWrites: false, reason: null };
|
|
26
|
-
|
|
27
|
-
|
|
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.19.
|
|
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"
|