@nurix/nustack 0.10.0-dev.8 → 0.11.0
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/README.md +1 -0
- package/dist/commands/bootstrap.js +4 -4
- package/dist/commands/docs.js +13 -13
- package/dist/commands/doctor.js +51 -17
- package/dist/commands/handoff.js +8 -8
- package/dist/commands/init.js +43 -43
- package/dist/commands/login.js +19 -6
- package/dist/commands/logout.js +14 -6
- package/dist/commands/logscope.js +55 -0
- package/dist/commands/{workspace-sync.js → project-sync.js} +5 -5
- package/dist/commands/{workspace.js → project.js} +133 -133
- package/dist/commands/review.js +38 -15
- package/dist/commands/search.js +7 -7
- package/dist/commands/sessions.js +156 -5
- package/dist/commands/sync.js +10 -10
- package/dist/commands/token.js +102 -0
- package/dist/commands/toolkit.js +19 -19
- package/dist/index.js +88 -36
- package/dist/lib/auth_grant.js +32 -0
- package/dist/lib/auth_recovery.js +3 -1
- package/dist/lib/credential_store.js +106 -0
- package/dist/lib/device_identity.js +6 -6
- package/dist/lib/device_keychain.js +16 -37
- package/dist/lib/etna/profile.js +9 -9
- package/dist/lib/etna/reconcile.js +11 -11
- package/dist/lib/git-sync/core/engine.js +4 -4
- package/dist/lib/git-sync/inbound/apply_commit.js +3 -3
- package/dist/lib/git-sync/inbound/approval.js +1 -1
- package/dist/lib/git-sync/inbound/download.js +8 -8
- package/dist/lib/git-sync/inbound/event_sync.js +13 -13
- package/dist/lib/git-sync/inbound/notification.js +1 -1
- package/dist/lib/git-sync/outbound/extraction.js +4 -4
- package/dist/lib/git-sync/outbound/history_decision.js +2 -2
- package/dist/lib/git-sync/outbound/publisher.js +2 -2
- package/dist/lib/git-sync/outbound/queue.js +3 -3
- package/dist/lib/git-sync/outbound/upload_client.js +4 -4
- package/dist/lib/git-sync/whitelist/index.js +1 -1
- package/dist/lib/git-sync/whitelist/types.js +1 -1
- package/dist/lib/git-sync/whitelist/validation.js +5 -5
- package/dist/lib/libsecret_store.js +47 -0
- package/dist/lib/local_store/accepted_uploads.js +5 -5
- package/dist/lib/local_store/bindings.js +11 -11
- package/dist/lib/local_store/index.js +1 -1
- package/dist/lib/local_store/migrations.js +13 -0
- package/dist/lib/local_store/project_events_cache.js +54 -0
- package/dist/lib/logs/answer.js +99 -0
- package/dist/lib/logs/library.js +19 -0
- package/dist/lib/logs/lifecycle.js +159 -0
- package/dist/lib/nustack_client.js +88 -46
- package/dist/lib/onboarding/attach_existing_git.js +7 -7
- package/dist/lib/onboarding/binding.js +11 -11
- package/dist/lib/onboarding/folder_state.js +10 -10
- package/dist/lib/onboarding/handoff_apply.js +2 -2
- package/dist/lib/onboarding/init_fresh_folder.js +18 -18
- package/dist/lib/onboarding/manifest_writer.js +31 -31
- package/dist/lib/onboarding/web_first_attach.js +12 -12
- package/dist/lib/{workspace_context.js → project_context.js} +24 -24
- package/dist/lib/review/client.js +1 -1
- package/dist/lib/{lanes → session-engine}/claudeAdapter.js +3 -3
- package/dist/lib/{lanes → session-engine}/frames.js +36 -27
- package/dist/lib/session-engine/prune.js +233 -0
- package/dist/lib/session-engine/registry.js +83 -0
- package/dist/lib/session-engine/supervisor.js +321 -0
- package/dist/lib/session-engine/worktree.js +89 -0
- package/dist/lib/session_telemetry.js +2 -2
- package/dist/lib/sessions/client.js +8 -8
- package/dist/lib/sessions/outsideSessions.js +6 -6
- package/dist/lib/telemetry/config_renderer.js +3 -3
- package/dist/lib/telemetry/health_report.js +1 -1
- package/package.json +5 -4
- package/dist/commands/lane.js +0 -12
- package/dist/lib/lanes/supervisor.js +0 -247
- package/dist/lib/lanes/worktree.js +0 -33
- package/dist/lib/local_store/workspace_events_cache.js +0 -54
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { ClaudeSession } from "./claudeAdapter.js";
|
|
6
|
+
import { FRAME_VERSION, parseInboundFrame, serializeFrame, } from "./frames.js";
|
|
7
|
+
import { inspectSession, reclaimSession } from "./prune.js";
|
|
8
|
+
import { sessionRegistryPath, readSessionRegistry, updateSessionRegistry } from "./registry.js";
|
|
9
|
+
import { createSessionWorktree } from "./worktree.js";
|
|
10
|
+
function defaultProbeClaude(env) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
execFile("claude", ["--version"], { env, timeout: 5_000 }, (error, stdout) => {
|
|
13
|
+
if (error)
|
|
14
|
+
resolve({ found: false, version: null });
|
|
15
|
+
else
|
|
16
|
+
resolve({ found: true, version: stdout.trim() });
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export class SessionSupervisor {
|
|
21
|
+
options;
|
|
22
|
+
env;
|
|
23
|
+
sessions = new Map();
|
|
24
|
+
closingSessions = new Set();
|
|
25
|
+
shuttingDown = false;
|
|
26
|
+
resolveDone = null;
|
|
27
|
+
lines = null;
|
|
28
|
+
registryWrites = Promise.resolve();
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
this.env = options.env ?? process.env;
|
|
32
|
+
}
|
|
33
|
+
emit(frame) {
|
|
34
|
+
this.options.output.write(serializeFrame(frame));
|
|
35
|
+
}
|
|
36
|
+
status(sessionId, status, message = null) {
|
|
37
|
+
this.emit({ v: FRAME_VERSION, type: "session.status", sessionId, status, message });
|
|
38
|
+
}
|
|
39
|
+
error(sessionId, code, message) {
|
|
40
|
+
this.emit({ v: FRAME_VERSION, type: "session.error", sessionId, code, message });
|
|
41
|
+
}
|
|
42
|
+
async boundProjectOf(projectPath) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(await readFile(path.join(projectPath, ".nustack", "workspace.json"), "utf8"));
|
|
45
|
+
const id = parsed?.workspaceId;
|
|
46
|
+
return typeof id === "string" && id.trim() ? id.trim() : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async moveSessionRow(record, to) {
|
|
53
|
+
const client = this.options.sessions;
|
|
54
|
+
if (!client || !record.projectId)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
await client.transitionSession(record.projectId, record.sessionId, to);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async updateRegistry(mutate) {
|
|
63
|
+
const run = () => updateSessionRegistry(this.env, mutate);
|
|
64
|
+
this.registryWrites = this.registryWrites.then(run, run);
|
|
65
|
+
return this.registryWrites;
|
|
66
|
+
}
|
|
67
|
+
async run() {
|
|
68
|
+
const probe = this.options.probeClaude ?? (() => defaultProbeClaude(this.env));
|
|
69
|
+
const claude = await probe();
|
|
70
|
+
this.emit({ v: FRAME_VERSION, type: "engine.hello", engineVersion: this.options.engineVersion, claude });
|
|
71
|
+
const lines = createInterface({ input: this.options.input });
|
|
72
|
+
this.lines = lines;
|
|
73
|
+
lines.on("line", (line) => {
|
|
74
|
+
if (line.trim() === "")
|
|
75
|
+
return;
|
|
76
|
+
const parsed = parseInboundFrame(line);
|
|
77
|
+
if (!parsed.ok) {
|
|
78
|
+
this.error(null, parsed.error.code, parsed.error.message);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
void this.handle(parsed.frame);
|
|
82
|
+
});
|
|
83
|
+
await new Promise((resolve) => {
|
|
84
|
+
this.resolveDone = resolve;
|
|
85
|
+
lines.on("close", () => void this.shutdown());
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async handle(frame) {
|
|
89
|
+
switch (frame.type) {
|
|
90
|
+
case "engine.shutdown":
|
|
91
|
+
await this.shutdown();
|
|
92
|
+
return;
|
|
93
|
+
case "session.open":
|
|
94
|
+
await this.openSession(frame.sessionId, frame.projectPath, frame.permissionMode, frame.isolated, frame.resumeSessionId, frame.commit);
|
|
95
|
+
return;
|
|
96
|
+
case "session.turn": {
|
|
97
|
+
const session = this.sessions.get(frame.sessionId);
|
|
98
|
+
if (!session)
|
|
99
|
+
return this.error(frame.sessionId, "SESSION_UNKNOWN", `no open session ${frame.sessionId}`);
|
|
100
|
+
session.adapter.sendTurn(frame.text);
|
|
101
|
+
this.status(frame.sessionId, "running");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
case "session.approval": {
|
|
105
|
+
const session = this.sessions.get(frame.sessionId);
|
|
106
|
+
if (!session)
|
|
107
|
+
return this.error(frame.sessionId, "SESSION_UNKNOWN", `no open session ${frame.sessionId}`);
|
|
108
|
+
session.adapter.respondApproval(frame.requestId, frame.verdict);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
case "session.interrupt": {
|
|
112
|
+
const session = this.sessions.get(frame.sessionId);
|
|
113
|
+
if (!session)
|
|
114
|
+
return this.error(frame.sessionId, "SESSION_UNKNOWN", `no open session ${frame.sessionId}`);
|
|
115
|
+
session.adapter.interrupt();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
case "session.close": {
|
|
119
|
+
const session = this.sessions.get(frame.sessionId);
|
|
120
|
+
if (!session)
|
|
121
|
+
return this.error(frame.sessionId, "SESSION_UNKNOWN", `no open session ${frame.sessionId}`);
|
|
122
|
+
this.closingSessions.add(frame.sessionId);
|
|
123
|
+
session.adapter.stop();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
case "session.list":
|
|
127
|
+
await this.sendRoster();
|
|
128
|
+
return;
|
|
129
|
+
case "session.reclaim":
|
|
130
|
+
await this.reclaimRow(frame.sessionId);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async openSession(sessionId, projectPath, permissionMode, isolated, resumeSessionId, commit) {
|
|
135
|
+
if (this.sessions.has(sessionId))
|
|
136
|
+
return this.error(sessionId, "SESSION_ALREADY_OPEN", `session ${sessionId} is already open`);
|
|
137
|
+
this.status(sessionId, "starting");
|
|
138
|
+
const worktree = await createSessionWorktree(projectPath, sessionId, this.env, isolated === true, commit);
|
|
139
|
+
if (worktree.commitRefusal !== null) {
|
|
140
|
+
this.error(sessionId, worktree.commitRefusal.code, worktree.commitRefusal.detail);
|
|
141
|
+
this.status(sessionId, "error", `could not open ${String(commit)}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const record = {
|
|
145
|
+
sessionId,
|
|
146
|
+
projectPath,
|
|
147
|
+
worktreePath: worktree.worktreePath,
|
|
148
|
+
branch: worktree.branch,
|
|
149
|
+
provider: "claude-code",
|
|
150
|
+
providerSessionId: null,
|
|
151
|
+
createdAt: new Date().toISOString(),
|
|
152
|
+
status: "open",
|
|
153
|
+
nustack: 1,
|
|
154
|
+
projectId: await this.boundProjectOf(projectPath),
|
|
155
|
+
permissionMode: permissionMode ?? null,
|
|
156
|
+
baseBranch: worktree.baseBranch,
|
|
157
|
+
baseCommit: worktree.baseCommit,
|
|
158
|
+
enginePid: process.pid,
|
|
159
|
+
closedAt: null,
|
|
160
|
+
};
|
|
161
|
+
const adapterOptions = {
|
|
162
|
+
cwd: worktree.worktreePath,
|
|
163
|
+
permissionMode,
|
|
164
|
+
resumeSessionId,
|
|
165
|
+
env: this.env,
|
|
166
|
+
onEvent: (event) => {
|
|
167
|
+
this.emit({ v: FRAME_VERSION, type: "session.event", sessionId, event });
|
|
168
|
+
if (typeof event === "object" && event !== null && event.type === "result") {
|
|
169
|
+
this.status(sessionId, "ready");
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
onApprovalRequest: (request) => {
|
|
173
|
+
this.emit({
|
|
174
|
+
v: FRAME_VERSION,
|
|
175
|
+
type: "session.approvalRequest",
|
|
176
|
+
sessionId,
|
|
177
|
+
requestId: request.requestId,
|
|
178
|
+
toolName: request.toolName,
|
|
179
|
+
displayName: request.displayName,
|
|
180
|
+
toolInput: request.toolInput,
|
|
181
|
+
description: request.description,
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
onProtocolError: (code, message) => this.error(sessionId, code, message),
|
|
185
|
+
onSessionId: (vendorSessionId) => {
|
|
186
|
+
record.providerSessionId = vendorSessionId;
|
|
187
|
+
void this.updateRegistry((sessions) => sessions.map((row) => (row.sessionId === sessionId ? { ...row, providerSessionId: vendorSessionId } : row)));
|
|
188
|
+
const client = this.options.sessions;
|
|
189
|
+
if (client && record.projectId) {
|
|
190
|
+
void client.setVendorSessionId(record.projectId, sessionId, vendorSessionId).catch(() => undefined);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
onExit: (code) => {
|
|
194
|
+
const wasExpected = this.closingSessions.delete(sessionId) || this.shuttingDown;
|
|
195
|
+
this.sessions.delete(sessionId);
|
|
196
|
+
if (!wasExpected)
|
|
197
|
+
this.error(sessionId, "ADAPTER_FAILED", `claude exited unexpectedly (code ${String(code)})`);
|
|
198
|
+
this.emit({ v: FRAME_VERSION, type: "session.closed", sessionId });
|
|
199
|
+
void this.updateRegistry((sessions) => sessions.map((row) => row.sessionId === sessionId ? { ...row, status: "closed", closedAt: new Date().toISOString() } : row));
|
|
200
|
+
void this.moveSessionRow(record, wasExpected ? "done" : "failed");
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
const create = this.options.createAdapter ?? ((opts) => new ClaudeSession(opts));
|
|
204
|
+
let adapter;
|
|
205
|
+
try {
|
|
206
|
+
adapter = create(adapterOptions);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
return this.error(sessionId, "ADAPTER_FAILED", error instanceof Error ? error.message : String(error));
|
|
210
|
+
}
|
|
211
|
+
this.sessions.set(sessionId, { adapter, record });
|
|
212
|
+
await this.updateRegistry((sessions) => [...sessions.filter((row) => row.sessionId !== sessionId), record]);
|
|
213
|
+
if (this.options.sessions && record.projectId) {
|
|
214
|
+
try {
|
|
215
|
+
await this.options.sessions.createSession(record.projectId, {
|
|
216
|
+
id: sessionId,
|
|
217
|
+
startedBy: "person",
|
|
218
|
+
substrate: "device",
|
|
219
|
+
mode: "synchronous",
|
|
220
|
+
nustack: 1,
|
|
221
|
+
vendor: "claude_code",
|
|
222
|
+
renderer: "claude_transcript",
|
|
223
|
+
status: "created",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.emit({
|
|
230
|
+
v: FRAME_VERSION,
|
|
231
|
+
type: "session.opened",
|
|
232
|
+
sessionId,
|
|
233
|
+
projectPath,
|
|
234
|
+
worktreePath: worktree.worktreePath,
|
|
235
|
+
branch: worktree.branch,
|
|
236
|
+
isolated: worktree.isolated,
|
|
237
|
+
commit: worktree.baseCommit,
|
|
238
|
+
});
|
|
239
|
+
this.status(sessionId, "ready");
|
|
240
|
+
}
|
|
241
|
+
async sendRoster() {
|
|
242
|
+
const { records, dropped } = await readSessionRegistry(this.env);
|
|
243
|
+
const rows = [];
|
|
244
|
+
for (const record of records) {
|
|
245
|
+
const attached = this.sessions.has(record.sessionId);
|
|
246
|
+
const facts = await inspectSession(record, this.env);
|
|
247
|
+
const reclaimable = !attached && facts.reclaimable;
|
|
248
|
+
const reason = attached ? "this engine is running it — close the session first" : facts.reason;
|
|
249
|
+
rows.push({
|
|
250
|
+
sessionId: record.sessionId,
|
|
251
|
+
projectPath: record.projectPath,
|
|
252
|
+
worktreePath: record.worktreePath,
|
|
253
|
+
branch: record.branch,
|
|
254
|
+
createdAt: record.createdAt,
|
|
255
|
+
status: record.status,
|
|
256
|
+
closedAt: record.closedAt ?? null,
|
|
257
|
+
permissionMode: record.permissionMode ?? null,
|
|
258
|
+
baseBranch: record.baseBranch ?? null,
|
|
259
|
+
attached,
|
|
260
|
+
liveElsewhere: !attached && record.status === "open" && typeof record.enginePid === "number" && !facts.stale,
|
|
261
|
+
isolated: facts.isolated,
|
|
262
|
+
checkoutPresent: facts.checkoutPresent,
|
|
263
|
+
worktreePresent: facts.worktreePresent,
|
|
264
|
+
branchExists: facts.branchExists,
|
|
265
|
+
reading: facts.reading,
|
|
266
|
+
dirty: facts.dirty,
|
|
267
|
+
stale: facts.stale,
|
|
268
|
+
reclaimable,
|
|
269
|
+
reason,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
this.emit({
|
|
273
|
+
v: FRAME_VERSION,
|
|
274
|
+
type: "session.roster",
|
|
275
|
+
registryPath: sessionRegistryPath(this.env),
|
|
276
|
+
unreadable: dropped,
|
|
277
|
+
sessions: rows,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async reclaimRow(sessionId) {
|
|
281
|
+
if (this.sessions.has(sessionId)) {
|
|
282
|
+
return this.error(sessionId, "SESSION_ATTACHED", `session ${sessionId} is open in this engine — close it first`);
|
|
283
|
+
}
|
|
284
|
+
const { records } = await readSessionRegistry(this.env);
|
|
285
|
+
const record = records.find((row) => row.sessionId === sessionId);
|
|
286
|
+
if (!record)
|
|
287
|
+
return this.error(sessionId, "SESSION_UNKNOWN", `no session ${sessionId} in this machine's registry`);
|
|
288
|
+
const reclaim = this.registryWrites.then(() => reclaimSession(record, {}, this.env), () => reclaimSession(record, {}, this.env));
|
|
289
|
+
this.registryWrites = reclaim.then(() => undefined, () => undefined);
|
|
290
|
+
try {
|
|
291
|
+
const outcome = await reclaim;
|
|
292
|
+
this.emit({
|
|
293
|
+
v: FRAME_VERSION,
|
|
294
|
+
type: "session.reclaimed",
|
|
295
|
+
sessionId,
|
|
296
|
+
removedWorktree: outcome.removedWorktree,
|
|
297
|
+
deletedBranch: outcome.deletedBranch,
|
|
298
|
+
droppedRow: outcome.droppedRow,
|
|
299
|
+
reason: outcome.reason,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
this.error(sessionId, "RECLAIM_FAILED", error instanceof Error ? error.message : String(error));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async shutdown() {
|
|
307
|
+
if (this.shuttingDown)
|
|
308
|
+
return;
|
|
309
|
+
this.shuttingDown = true;
|
|
310
|
+
const held = [...this.sessions.keys()];
|
|
311
|
+
for (const [, session] of this.sessions)
|
|
312
|
+
session.adapter.stop();
|
|
313
|
+
this.sessions.clear();
|
|
314
|
+
if (held.length > 0) {
|
|
315
|
+
const closedAt = new Date().toISOString();
|
|
316
|
+
await this.updateRegistry((sessions) => sessions.map((row) => (held.includes(row.sessionId) && row.status === "open" ? { ...row, status: "closed", closedAt } : row)));
|
|
317
|
+
}
|
|
318
|
+
this.lines?.close();
|
|
319
|
+
this.resolveDone?.();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const ENGINE_SESSION_BRANCH_PREFIX = "nustack/session-";
|
|
6
|
+
export function sessionsHome(env = process.env) {
|
|
7
|
+
const override = env.NUSTACK_SESSIONS_HOME;
|
|
8
|
+
if (override && override !== "")
|
|
9
|
+
return override;
|
|
10
|
+
return path.join(os.homedir(), ".nustack", "sessions");
|
|
11
|
+
}
|
|
12
|
+
export const LEGACY_ENGINE_BRANCH_PREFIX = "nustack/lane-";
|
|
13
|
+
const FETCH_TIMEOUT_MS = 10 * 60_000;
|
|
14
|
+
export function legacyEngineHome(env = process.env) {
|
|
15
|
+
const override = env.NUSTACK_LEGACY_ENGINE_HOME;
|
|
16
|
+
if (override && override !== "")
|
|
17
|
+
return override;
|
|
18
|
+
return path.join(os.homedir(), ".nustack", "lanes");
|
|
19
|
+
}
|
|
20
|
+
function git(args, cwd) {
|
|
21
|
+
return gitOutcome(args, cwd).then((outcome) => (outcome.ok ? outcome.stdout : null));
|
|
22
|
+
}
|
|
23
|
+
export function gitOutcome(args, cwd, timeoutMs = 15_000) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const failed = (error) => {
|
|
26
|
+
resolve({ ok: false, code: null, stdout: "", stderr: error instanceof Error ? error.message : String(error) });
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
execFile("git", args, { cwd, timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
30
|
+
const raw = error?.code;
|
|
31
|
+
const code = error ? (typeof raw === "number" ? raw : null) : 0;
|
|
32
|
+
resolve({ ok: !error, code, stdout: stdout.trim(), stderr: stderr.trim() });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
failed(error);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export async function createSessionWorktree(projectPath, sessionId, env = process.env, isolate = false, commit) {
|
|
41
|
+
const inPlace = {
|
|
42
|
+
worktreePath: projectPath,
|
|
43
|
+
branch: null,
|
|
44
|
+
isolated: false,
|
|
45
|
+
baseBranch: null,
|
|
46
|
+
baseCommit: null,
|
|
47
|
+
commitRefusal: null,
|
|
48
|
+
};
|
|
49
|
+
if (!isolate && commit === undefined)
|
|
50
|
+
return inPlace;
|
|
51
|
+
const isRepo = await git(["rev-parse", "--is-inside-work-tree"], projectPath);
|
|
52
|
+
if (isRepo !== "true") {
|
|
53
|
+
return commit === undefined
|
|
54
|
+
? inPlace
|
|
55
|
+
: { ...inPlace, commitRefusal: { code: "COMMIT_UNREACHABLE", detail: `${projectPath} is not a git checkout, so ${commit} cannot be opened here.` } };
|
|
56
|
+
}
|
|
57
|
+
if (commit !== undefined) {
|
|
58
|
+
const refusal = await fetchCommit(projectPath, commit);
|
|
59
|
+
if (refusal !== null)
|
|
60
|
+
return { ...inPlace, commitRefusal: refusal };
|
|
61
|
+
}
|
|
62
|
+
const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], projectPath);
|
|
63
|
+
const baseBranch = head === null || head === "HEAD" ? null : head;
|
|
64
|
+
const branch = `${ENGINE_SESSION_BRANCH_PREFIX}${sessionId}`;
|
|
65
|
+
const worktreePath = path.join(sessionsHome(env), "worktrees", sessionId);
|
|
66
|
+
await mkdir(path.dirname(worktreePath), { recursive: true });
|
|
67
|
+
const added = await git(commit === undefined
|
|
68
|
+
? ["worktree", "add", worktreePath, "-b", branch]
|
|
69
|
+
: ["worktree", "add", worktreePath, "-b", branch, commit], projectPath);
|
|
70
|
+
if (added === null) {
|
|
71
|
+
return commit === undefined
|
|
72
|
+
? inPlace
|
|
73
|
+
: { ...inPlace, commitRefusal: { code: "COMMIT_UNREACHABLE", detail: `git refused a worktree for ${commit} in ${projectPath}.` } };
|
|
74
|
+
}
|
|
75
|
+
const baseCommit = await git(["rev-parse", "HEAD"], worktreePath);
|
|
76
|
+
return { worktreePath, branch, isolated: true, baseBranch, baseCommit, commitRefusal: null };
|
|
77
|
+
}
|
|
78
|
+
async function fetchCommit(projectPath, commit) {
|
|
79
|
+
const has = () => gitOutcome(["cat-file", "-e", `${commit}^{commit}`], projectPath);
|
|
80
|
+
if ((await has()).ok)
|
|
81
|
+
return null;
|
|
82
|
+
let fetched = await gitOutcome(["fetch", "origin", commit], projectPath, FETCH_TIMEOUT_MS);
|
|
83
|
+
if (!fetched.ok)
|
|
84
|
+
fetched = await gitOutcome(["fetch", "origin"], projectPath, FETCH_TIMEOUT_MS);
|
|
85
|
+
if ((await has()).ok)
|
|
86
|
+
return null;
|
|
87
|
+
const said = `${fetched.stderr}\n${fetched.stdout}`.trim();
|
|
88
|
+
return { code: "COMMIT_UNREACHABLE", detail: said === "" ? `${commit} is not in this checkout and origin did not have it.` : said };
|
|
89
|
+
}
|
|
@@ -44,7 +44,7 @@ function toTelemetryBinding(binding, deviceRef, userId) {
|
|
|
44
44
|
bindingId: binding.id,
|
|
45
45
|
repoRoot: binding.repositoryRoot,
|
|
46
46
|
organizationId: binding.organizationId,
|
|
47
|
-
|
|
47
|
+
projectId: binding.projectId,
|
|
48
48
|
deviceRef,
|
|
49
49
|
userId,
|
|
50
50
|
};
|
|
@@ -122,7 +122,7 @@ export async function ensureBindingTelemetry(opts) {
|
|
|
122
122
|
}
|
|
123
123
|
let brokerFailure = null;
|
|
124
124
|
const credential = await fetchBrokeredCredential(baseUrl, auth.accessToken, {
|
|
125
|
-
|
|
125
|
+
projectId: binding.projectId,
|
|
126
126
|
service: TELEMETRY_SERVICE,
|
|
127
127
|
}).catch((error) => {
|
|
128
128
|
brokerFailure = error instanceof Error ? error.message : String(error);
|
|
@@ -23,19 +23,19 @@ export function makeSessionsClient(baseUrl, accessToken) {
|
|
|
23
23
|
return { status: response.status, session: payload.data };
|
|
24
24
|
}
|
|
25
25
|
return {
|
|
26
|
-
async createSession(
|
|
27
|
-
const safeId = assertSafeOpaqueId("The
|
|
28
|
-
const answered = await post(`/api/v2/
|
|
26
|
+
async createSession(projectId, input) {
|
|
27
|
+
const safeId = assertSafeOpaqueId("The Project id", projectId);
|
|
28
|
+
const answered = await post(`/api/v2/projects/${encodeURIComponent(safeId)}/sessions`, input, "session create failed");
|
|
29
29
|
return { created: answered.status === 201, session: answered.session };
|
|
30
30
|
},
|
|
31
|
-
async transitionSession(
|
|
32
|
-
const
|
|
31
|
+
async transitionSession(projectId, sessionId, to, patch = {}) {
|
|
32
|
+
const safeProject = assertSafeOpaqueId("The Project id", projectId);
|
|
33
33
|
const safeSession = assertSafeOpaqueId("The session id", sessionId);
|
|
34
|
-
const answered = await post(`/api/v2/
|
|
34
|
+
const answered = await post(`/api/v2/projects/${encodeURIComponent(safeProject)}/sessions/${encodeURIComponent(safeSession)}/transition`, { to, ...patch }, "session transition failed");
|
|
35
35
|
return answered.session;
|
|
36
36
|
},
|
|
37
|
-
async setVendorSessionId(
|
|
38
|
-
return this.transitionSession(
|
|
37
|
+
async setVendorSessionId(projectId, sessionId, vendorSessionId) {
|
|
38
|
+
return this.transitionSession(projectId, sessionId, "running", { vendorSessionId });
|
|
39
39
|
},
|
|
40
40
|
};
|
|
41
41
|
}
|
|
@@ -9,10 +9,10 @@ function vendorOf(provider) {
|
|
|
9
9
|
const AT_REST = new Set(["done", "failed", "cancelled"]);
|
|
10
10
|
export async function recordOutsideSessions(client, input) {
|
|
11
11
|
const now = input.now ?? Date.now;
|
|
12
|
-
const
|
|
12
|
+
const engineIds = new Set((input.engineSessionIds ?? []).filter((id) => Boolean(id)));
|
|
13
13
|
const report = { created: 0, finished: 0, skipped: 0, failed: 0 };
|
|
14
14
|
for (const transcript of input.transcripts) {
|
|
15
|
-
if (!transcript.
|
|
15
|
+
if (!transcript.projectId) {
|
|
16
16
|
report.skipped += 1;
|
|
17
17
|
continue;
|
|
18
18
|
}
|
|
@@ -23,14 +23,14 @@ export async function recordOutsideSessions(client, input) {
|
|
|
23
23
|
}
|
|
24
24
|
const stale = now() - transcript.modifiedMs >= STOPPED_GROWING_MS;
|
|
25
25
|
const vendorSessionId = transcript.vendorSessionId?.trim() || null;
|
|
26
|
-
const
|
|
26
|
+
const isEngineSession = vendorSessionId !== null && engineIds.has(vendorSessionId);
|
|
27
27
|
try {
|
|
28
|
-
const { created, session } = await client.createSession(transcript.
|
|
28
|
+
const { created, session } = await client.createSession(transcript.projectId, {
|
|
29
29
|
id: transcript.sessionKey,
|
|
30
30
|
startedBy: "person",
|
|
31
31
|
substrate: "device",
|
|
32
32
|
mode: "synchronous",
|
|
33
|
-
|
|
33
|
+
nustack: isEngineSession ? 1 : 0,
|
|
34
34
|
vendor: kinds.vendor,
|
|
35
35
|
renderer: kinds.renderer,
|
|
36
36
|
status: stale ? "done" : "running",
|
|
@@ -39,7 +39,7 @@ export async function recordOutsideSessions(client, input) {
|
|
|
39
39
|
if (created)
|
|
40
40
|
report.created += 1;
|
|
41
41
|
if (stale && !AT_REST.has(session.status)) {
|
|
42
|
-
await client.transitionSession(transcript.
|
|
42
|
+
await client.transitionSession(transcript.projectId, transcript.sessionKey, "done");
|
|
43
43
|
report.finished += 1;
|
|
44
44
|
}
|
|
45
45
|
else if (!created) {
|
|
@@ -92,7 +92,7 @@ const BINDING_TEMPLATE = `# Managed by the nustack CLI — binding: {{binding_id
|
|
|
92
92
|
nustack:
|
|
93
93
|
schema_version: {{schema_version}}
|
|
94
94
|
organization_id: {{organization_id}}
|
|
95
|
-
|
|
95
|
+
project_id: {{project_id}}
|
|
96
96
|
binding_id: {{binding_id}}
|
|
97
97
|
device_id: {{device_id}}
|
|
98
98
|
user_id: {{user_id}}
|
|
@@ -116,7 +116,7 @@ export function renderBindingInput(binding) {
|
|
|
116
116
|
claude_glob: yamlScalar(`${claudeProjectDir(binding.repoRoot)}/**/*.jsonl`),
|
|
117
117
|
schema_version: String(ENVELOPE_SCHEMA_VERSION),
|
|
118
118
|
organization_id: yamlScalar(binding.organizationId),
|
|
119
|
-
|
|
119
|
+
project_id: yamlScalar(binding.projectId),
|
|
120
120
|
binding_id: yamlScalar(binding.bindingId),
|
|
121
121
|
device_id: yamlScalar(binding.deviceRef),
|
|
122
122
|
user_id: yamlScalar(binding.userId),
|
|
@@ -142,7 +142,7 @@ export function renderCodexSharedInput(bindings) {
|
|
|
142
142
|
target: nustack
|
|
143
143
|
fields:
|
|
144
144
|
organization_id: ${yamlScalar(binding.organizationId)}
|
|
145
|
-
|
|
145
|
+
project_id: ${yamlScalar(binding.projectId)}
|
|
146
146
|
binding_id: ${yamlScalar(binding.bindingId)}`)
|
|
147
147
|
.join("\n");
|
|
148
148
|
const dropList = ["cwd", ...providerDropFields("codex")].join(", ");
|
|
@@ -38,7 +38,7 @@ export async function collectTelemetryStatus(store, probes = defaultProbes(), op
|
|
|
38
38
|
bindingId: binding.id,
|
|
39
39
|
repoRoot: binding.repositoryRoot,
|
|
40
40
|
organizationId: binding.organizationId,
|
|
41
|
-
|
|
41
|
+
projectId: binding.projectId,
|
|
42
42
|
deviceRef,
|
|
43
43
|
userId,
|
|
44
44
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/nustack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
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,
|
|
@@ -26,15 +26,16 @@
|
|
|
26
26
|
"commander": "^14.0.0"
|
|
27
27
|
},
|
|
28
28
|
"optionalDependencies": {
|
|
29
|
-
"@nurix/codegraph": "0.2.1"
|
|
29
|
+
"@nurix/codegraph": "0.2.1",
|
|
30
|
+
"@nurix/logscope": "0.1.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@ngneat/falso": "^8.0.2",
|
|
33
34
|
"@types/node": "^22.19.20",
|
|
34
35
|
"typescript": "^5.7.2",
|
|
35
36
|
"vitest": "^4.1.11",
|
|
36
|
-
"@nurix/nustack.
|
|
37
|
-
"@nurix/nustack.
|
|
37
|
+
"@nurix/nustack.ui": "0.0.0",
|
|
38
|
+
"@nurix/nustack.studio": "0.0.0"
|
|
38
39
|
},
|
|
39
40
|
"publishConfig": {
|
|
40
41
|
"access": "public"
|
package/dist/commands/lane.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { LaneSupervisor } from "../lib/lanes/supervisor.js";
|
|
2
|
-
import { sessionsClientFromEnv } from "../lib/sessions/client.js";
|
|
3
|
-
export async function runLaneServe(engineVersion) {
|
|
4
|
-
const supervisor = new LaneSupervisor({
|
|
5
|
-
input: process.stdin,
|
|
6
|
-
output: process.stdout,
|
|
7
|
-
engineVersion,
|
|
8
|
-
sessions: sessionsClientFromEnv(process.env),
|
|
9
|
-
});
|
|
10
|
-
await supervisor.run();
|
|
11
|
-
process.exit(0);
|
|
12
|
-
}
|