dahrk-node 0.1.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/LICENSE +201 -0
- package/README.md +35 -0
- package/dist/main.js +3050 -0
- package/package.json +56 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,3050 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/main.ts
|
|
4
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync6, readFileSync as readFileSync5, realpathSync, writeFileSync as writeFileSync6 } from "fs";
|
|
5
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
6
|
+
import { homedir as homedir2 } from "os";
|
|
7
|
+
import { basename, join as join7 } from "path";
|
|
8
|
+
import { pathToFileURL } from "url";
|
|
9
|
+
|
|
10
|
+
// ../../packages/edge/src/ws-client.ts
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
12
|
+
import { arch as osArch, platform as osPlatform } from "os";
|
|
13
|
+
import { WebSocket } from "ws";
|
|
14
|
+
import { decode, encode, isEnrolmentRejection } from "@dahrk/contracts";
|
|
15
|
+
|
|
16
|
+
// ../../packages/executor-worktree/src/mock-runner.ts
|
|
17
|
+
var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
18
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
var COMPLETE_SENTINEL = "__complete__";
|
|
20
|
+
function createMockRunner(runtime) {
|
|
21
|
+
let cancelled = false;
|
|
22
|
+
return {
|
|
23
|
+
runtime,
|
|
24
|
+
async runBatch(ctx, onTrace) {
|
|
25
|
+
const tool2 = ctx.config.tools?.[0] ?? "shell";
|
|
26
|
+
const toolUseId = "mock-tool-1";
|
|
27
|
+
const events = [
|
|
28
|
+
{ seq: 0, runtime, type: "thought", ts: nowIso(), text: "mock: planning the stage" },
|
|
29
|
+
{ seq: 1, runtime, type: "action", ts: nowIso(), tool: tool2, toolUseId, input: { note: "mock action" } },
|
|
30
|
+
{ seq: 2, runtime, type: "observation", ts: nowIso(), toolUseId, output: { ok: true } },
|
|
31
|
+
{ seq: 3, runtime, type: "response", ts: nowIso(), text: "mock: stage complete" }
|
|
32
|
+
];
|
|
33
|
+
for (const event of events) onTrace(event);
|
|
34
|
+
const delayMs = Number(process.env.DAHRK_MOCK_DELAY_MS ?? process.env.SKAKEL_MOCK_DELAY_MS ?? 0);
|
|
35
|
+
if (delayMs > 0) await sleep(delayMs);
|
|
36
|
+
const costUsd = Number(process.env.DAHRK_MOCK_COST_USD ?? process.env.SKAKEL_MOCK_COST_USD ?? 0);
|
|
37
|
+
return { status: cancelled ? "fail" : "ok", ...costUsd > 0 ? { costUsd } : {} };
|
|
38
|
+
},
|
|
39
|
+
async runInteractive(_ctx, turns, onTrace) {
|
|
40
|
+
let count = 0;
|
|
41
|
+
let last = "";
|
|
42
|
+
let toolExit = false;
|
|
43
|
+
for await (const turn of turns) {
|
|
44
|
+
if (turn.text === COMPLETE_SENTINEL) {
|
|
45
|
+
toolExit = true;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
count++;
|
|
49
|
+
last = turn.text;
|
|
50
|
+
onTrace({ seq: 0, runtime, type: "thought", ts: nowIso(), text: `mock: heard "${turn.text}"` });
|
|
51
|
+
onTrace({ seq: 0, runtime, type: "response", ts: nowIso(), text: `mock: ack ${count}` });
|
|
52
|
+
}
|
|
53
|
+
if (cancelled) {
|
|
54
|
+
return { status: "fail", summary: "mock interactive: cancelled" };
|
|
55
|
+
}
|
|
56
|
+
const summary = toolExit ? `mock interactive complete (tool) after ${count} turns` : `mock interactive complete (gate) after ${count} turns: ${last}`;
|
|
57
|
+
return { status: "ok", summary };
|
|
58
|
+
},
|
|
59
|
+
async summarise(ctx) {
|
|
60
|
+
return `mock summary: ${ctx.config.runtime} stage complete`;
|
|
61
|
+
},
|
|
62
|
+
async cancel() {
|
|
63
|
+
cancelled = true;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ../../packages/executor-worktree/src/claude-adapter.ts
|
|
69
|
+
import {
|
|
70
|
+
query
|
|
71
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
72
|
+
|
|
73
|
+
// ../../packages/executor-worktree/src/claude-mappers.ts
|
|
74
|
+
function mapClaudeMessage(msg) {
|
|
75
|
+
switch (msg.type) {
|
|
76
|
+
case "assistant": {
|
|
77
|
+
const events = [];
|
|
78
|
+
const blocks = Array.isArray(msg.message.content) ? msg.message.content : [];
|
|
79
|
+
let text = "";
|
|
80
|
+
let hasToolUse = false;
|
|
81
|
+
for (const b of blocks) {
|
|
82
|
+
if (b.type === "text") text += String(b.text ?? "");
|
|
83
|
+
else if (b.type === "thinking")
|
|
84
|
+
events.push({ type: "thought", subtype: "reasoning_text", text: String(b.thinking ?? "") });
|
|
85
|
+
else if (b.type === "redacted_thinking")
|
|
86
|
+
events.push({ type: "thought", subtype: "reasoning_text", text: "[redacted]" });
|
|
87
|
+
else if (b.type === "tool_use") {
|
|
88
|
+
hasToolUse = true;
|
|
89
|
+
events.push({ type: "action", tool: String(b.name), toolUseId: String(b.id), input: b.input });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const err = msg.error;
|
|
93
|
+
if (err) events.push({ type: "error", kind: "assistant_error", message: String(err.message ?? err) });
|
|
94
|
+
return { events, recognised: true, assistantText: text, hasToolUse };
|
|
95
|
+
}
|
|
96
|
+
case "user": {
|
|
97
|
+
const events = [];
|
|
98
|
+
const content = msg.message.content;
|
|
99
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
100
|
+
for (const b of blocks) {
|
|
101
|
+
if (b.type === "tool_result") {
|
|
102
|
+
events.push({
|
|
103
|
+
type: "observation",
|
|
104
|
+
toolUseId: String(b.tool_use_id),
|
|
105
|
+
output: b.content,
|
|
106
|
+
isError: Boolean(b.is_error)
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { events, recognised: true };
|
|
111
|
+
}
|
|
112
|
+
case "result": {
|
|
113
|
+
const m = msg;
|
|
114
|
+
const status = m.subtype === "success" ? "ok" : "fail";
|
|
115
|
+
const events = [
|
|
116
|
+
{
|
|
117
|
+
type: "state",
|
|
118
|
+
event: "stage-exit",
|
|
119
|
+
status,
|
|
120
|
+
usage: mapUsage(m.usage),
|
|
121
|
+
costUsd: m.total_cost_usd,
|
|
122
|
+
durationMs: m.duration_ms
|
|
123
|
+
}
|
|
124
|
+
];
|
|
125
|
+
if (status !== "ok") events.push({ type: "error", kind: "result_error", message: m.subtype });
|
|
126
|
+
return { events, recognised: true, resultStatus: status };
|
|
127
|
+
}
|
|
128
|
+
// Control / metadata: recognised, captured in the raw sidecar, not normalised.
|
|
129
|
+
case "system":
|
|
130
|
+
case "stream_event":
|
|
131
|
+
case "rate_limit_event":
|
|
132
|
+
return { events: [], recognised: true };
|
|
133
|
+
default:
|
|
134
|
+
return { events: [], recognised: false };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function mapUsage(u) {
|
|
138
|
+
return {
|
|
139
|
+
input: u?.input_tokens ?? 0,
|
|
140
|
+
output: u?.output_tokens ?? 0,
|
|
141
|
+
cacheRead: u?.cache_read_input_tokens ?? 0,
|
|
142
|
+
cacheCreate: u?.cache_creation_input_tokens ?? 0
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
var newBufferState = () => ({ bufferedText: null, turnEndedOnTool: false });
|
|
146
|
+
function consumeClaudeMessage(msg, state, suppressStageExit) {
|
|
147
|
+
const r = mapClaudeMessage(msg);
|
|
148
|
+
const events = [];
|
|
149
|
+
if (msg.type === "assistant") {
|
|
150
|
+
events.push(...r.events);
|
|
151
|
+
const text = (r.assistantText ?? "").trim();
|
|
152
|
+
if (text) {
|
|
153
|
+
if (state.bufferedText) events.push({ type: "thought", text: state.bufferedText });
|
|
154
|
+
state.bufferedText = text;
|
|
155
|
+
state.turnEndedOnTool = false;
|
|
156
|
+
} else if (r.hasToolUse) {
|
|
157
|
+
if (state.bufferedText) {
|
|
158
|
+
events.push({ type: "thought", text: state.bufferedText });
|
|
159
|
+
state.bufferedText = null;
|
|
160
|
+
}
|
|
161
|
+
state.turnEndedOnTool = true;
|
|
162
|
+
}
|
|
163
|
+
return { events, isResult: false };
|
|
164
|
+
}
|
|
165
|
+
if (msg.type === "result") {
|
|
166
|
+
const status = r.resultStatus === "fail" ? "fail" : "ok";
|
|
167
|
+
let responseText;
|
|
168
|
+
if (status === "ok" && state.bufferedText && !state.turnEndedOnTool) {
|
|
169
|
+
responseText = state.bufferedText;
|
|
170
|
+
events.push({ type: "response", text: state.bufferedText });
|
|
171
|
+
}
|
|
172
|
+
for (const e of r.events) {
|
|
173
|
+
if (suppressStageExit && e.type === "state") continue;
|
|
174
|
+
events.push(e);
|
|
175
|
+
}
|
|
176
|
+
state.bufferedText = null;
|
|
177
|
+
state.turnEndedOnTool = false;
|
|
178
|
+
return { events, isResult: true, status, responseText };
|
|
179
|
+
}
|
|
180
|
+
events.push(...r.events);
|
|
181
|
+
return { events, isResult: false };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ../../packages/executor-worktree/src/runner-shared.ts
|
|
185
|
+
import { readFileSync } from "fs";
|
|
186
|
+
import { join } from "path";
|
|
187
|
+
import { attachedDocBasename } from "@dahrk/contracts";
|
|
188
|
+
var SUMMARISE_PROMPT = "This stage is ending. In ONE concise sentence for a human reviewer reading the Linear ticket, state concretely what you DID or FOUND in this stage and the result (for example which functions or files changed, whether the tests pass, or what a review flagged). Be specific and lead with the outcome. Do not mention internal scratch files or a next-stage entry point. Reply with only that sentence.";
|
|
189
|
+
function makeEmit(runtime, onTrace, now = () => (/* @__PURE__ */ new Date()).toISOString()) {
|
|
190
|
+
return (event, rawRef) => {
|
|
191
|
+
const full = { ...event, seq: 0, ts: now(), runtime };
|
|
192
|
+
if (rawRef !== void 0) full.rawRef = rawRef;
|
|
193
|
+
onTrace(full);
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function stripFrontmatter(text) {
|
|
197
|
+
const lines = text.split("\n");
|
|
198
|
+
if (lines[0]?.trim() !== "---") return text;
|
|
199
|
+
for (let i = 1; i < lines.length; i++) {
|
|
200
|
+
if (lines[i]?.trim() === "---") {
|
|
201
|
+
return lines.slice(i + 1).join("\n").replace(/^\n+/, "");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return text;
|
|
205
|
+
}
|
|
206
|
+
function stageInstruction(ctx) {
|
|
207
|
+
const { config, workspace } = ctx;
|
|
208
|
+
if (config.promptFile) {
|
|
209
|
+
try {
|
|
210
|
+
const raw = readFileSync(join(workspace.worktreePath, config.promptFile), "utf8");
|
|
211
|
+
const body = stripFrontmatter(raw).trim();
|
|
212
|
+
if (body) return body;
|
|
213
|
+
} catch (e) {
|
|
214
|
+
return `The configured prompt file "${config.promptFile}" could not be read (${e.message}).`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (config.prompt) return config.prompt;
|
|
218
|
+
if (config.skill) return `Use the ${config.skill} skill to complete this stage.`;
|
|
219
|
+
return "Begin the stage.";
|
|
220
|
+
}
|
|
221
|
+
var MAX_INLINE_DOC_CHARS = 6e3;
|
|
222
|
+
var MAX_INLINE_DOCS_TOTAL_CHARS = 2e4;
|
|
223
|
+
function neutraliseDelimiters(text) {
|
|
224
|
+
return text.replace(/<\/(documents?)>/gi, "<\u200B/$1>");
|
|
225
|
+
}
|
|
226
|
+
function documentsBlock(ctx) {
|
|
227
|
+
const docs = ctx.attachedDocuments;
|
|
228
|
+
if (!docs || docs.length === 0) return "";
|
|
229
|
+
let budget = MAX_INLINE_DOCS_TOTAL_CHARS;
|
|
230
|
+
const parts = [];
|
|
231
|
+
for (const doc of docs) {
|
|
232
|
+
const path = `.skakel/scratch/docs/${attachedDocBasename(doc)}.md`;
|
|
233
|
+
const cap = Math.max(0, Math.min(MAX_INLINE_DOC_CHARS, budget));
|
|
234
|
+
const body = doc.content.trim();
|
|
235
|
+
const truncated = body.length > cap;
|
|
236
|
+
const excerpt = truncated ? body.slice(0, cap) : body;
|
|
237
|
+
budget -= excerpt.length;
|
|
238
|
+
const tail = truncated ? `
|
|
239
|
+
...(truncated; full text at ${path})` : "";
|
|
240
|
+
const title = doc.title.replace(/[<>"]/g, "'");
|
|
241
|
+
parts.push(
|
|
242
|
+
`<document title="${title}" file="${path}">
|
|
243
|
+
${neutraliseDelimiters(excerpt)}${tail}
|
|
244
|
+
</document>`
|
|
245
|
+
);
|
|
246
|
+
if (budget <= 0) break;
|
|
247
|
+
}
|
|
248
|
+
return `<documents>
|
|
249
|
+
${parts.join("\n\n")}
|
|
250
|
+
</documents>`;
|
|
251
|
+
}
|
|
252
|
+
function neutraliseGuidanceDelimiters(text) {
|
|
253
|
+
return text.replace(/<\/(guidance(?:-rule)?)>/gi, "<\u200B/$1>");
|
|
254
|
+
}
|
|
255
|
+
function guidanceBlock(ctx) {
|
|
256
|
+
const guidance = ctx.guidance;
|
|
257
|
+
if (!guidance || guidance.length === 0) return "";
|
|
258
|
+
const parts = [];
|
|
259
|
+
for (const rule of guidance) {
|
|
260
|
+
const content = rule.content.trim();
|
|
261
|
+
if (!content) continue;
|
|
262
|
+
const origin = rule.origin.replace(/[<>"]/g, "'");
|
|
263
|
+
const team = rule.teamName !== void 0 ? ` team="${rule.teamName.replace(/[<>"]/g, "'")}"` : "";
|
|
264
|
+
parts.push(`<guidance-rule origin="${origin}"${team}>
|
|
265
|
+
${neutraliseGuidanceDelimiters(content)}
|
|
266
|
+
</guidance-rule>`);
|
|
267
|
+
}
|
|
268
|
+
if (parts.length === 0) return "";
|
|
269
|
+
return `<guidance>
|
|
270
|
+
${parts.join("\n")}
|
|
271
|
+
</guidance>`;
|
|
272
|
+
}
|
|
273
|
+
function neutraliseGateFeedbackDelimiters(text) {
|
|
274
|
+
return text.replace(/<\/(gate-feedback|gate-note)>/gi, "<\u200B/$1>");
|
|
275
|
+
}
|
|
276
|
+
function gateFeedbackBlock(ctx) {
|
|
277
|
+
const notes = ctx.gateFeedback;
|
|
278
|
+
if (!notes || notes.length === 0) return "";
|
|
279
|
+
const parts = [];
|
|
280
|
+
for (const note of notes) {
|
|
281
|
+
const content = note.feedback.trim();
|
|
282
|
+
if (!content) continue;
|
|
283
|
+
const stage = note.stageId.replace(/[<>"]/g, "'");
|
|
284
|
+
const decision = note.decision.replace(/[<>"]/g, "'");
|
|
285
|
+
parts.push(
|
|
286
|
+
`<gate-note stage="${stage}" decision="${decision}">
|
|
287
|
+
${neutraliseGateFeedbackDelimiters(content)}
|
|
288
|
+
</gate-note>`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (parts.length === 0) return "";
|
|
292
|
+
return `<gate-feedback>
|
|
293
|
+
${parts.join("\n")}
|
|
294
|
+
</gate-feedback>`;
|
|
295
|
+
}
|
|
296
|
+
function resolveStagePrompt(ctx) {
|
|
297
|
+
const instruction = stageInstruction(ctx);
|
|
298
|
+
const ticket = ctx.issueContext?.trim() ? `<ticket>
|
|
299
|
+
${ctx.issueContext.trim()}
|
|
300
|
+
</ticket>` : "";
|
|
301
|
+
const guidance = guidanceBlock(ctx);
|
|
302
|
+
const gateFeedback = gateFeedbackBlock(ctx);
|
|
303
|
+
const docs = documentsBlock(ctx);
|
|
304
|
+
const preamble = [ticket, guidance, gateFeedback, docs].filter(Boolean).join("\n\n");
|
|
305
|
+
return preamble ? `${preamble}
|
|
306
|
+
|
|
307
|
+
${instruction}` : instruction;
|
|
308
|
+
}
|
|
309
|
+
function hasSystemPrompt(ctx) {
|
|
310
|
+
return Boolean(
|
|
311
|
+
ctx.config.prompt || ctx.config.promptFile || ctx.issueContext?.trim() || ctx.guidance && ctx.guidance.length > 0 || ctx.gateFeedback && ctx.gateFeedback.length > 0 || ctx.attachedDocuments && ctx.attachedDocuments.length > 0
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
var OPENING_KICKOFF = "Begin now. Using the ticket context and your instructions already provided, ask the human your first question. Do not wait for further input before sending your first message.";
|
|
315
|
+
function interactiveSeedText(ctx, instructionInSystemPrompt) {
|
|
316
|
+
return instructionInSystemPrompt ? OPENING_KICKOFF : resolveStagePrompt(ctx);
|
|
317
|
+
}
|
|
318
|
+
var ManagedMailbox = class {
|
|
319
|
+
q = [];
|
|
320
|
+
waiters = [];
|
|
321
|
+
done = false;
|
|
322
|
+
push(value) {
|
|
323
|
+
const w = this.waiters.shift();
|
|
324
|
+
if (w) w({ value, done: false });
|
|
325
|
+
else this.q.push(value);
|
|
326
|
+
}
|
|
327
|
+
end() {
|
|
328
|
+
this.done = true;
|
|
329
|
+
let w;
|
|
330
|
+
while (w = this.waiters.shift()) w({ value: void 0, done: true });
|
|
331
|
+
}
|
|
332
|
+
[Symbol.asyncIterator]() {
|
|
333
|
+
return {
|
|
334
|
+
next: () => {
|
|
335
|
+
const v = this.q.shift();
|
|
336
|
+
if (v !== void 0) return Promise.resolve({ value: v, done: false });
|
|
337
|
+
if (this.done) return Promise.resolve({ value: void 0, done: true });
|
|
338
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
function interactiveIdleWindows(ctx) {
|
|
344
|
+
const idleMs = ctx.config.idleMs ?? Number(process.env.DAHRK_INTERACTIVE_IDLE_MS ?? process.env.SKAKEL_INTERACTIVE_IDLE_MS ?? 12e4);
|
|
345
|
+
const firstReplyMs = ctx.config.firstReplyMs ?? Number(process.env.DAHRK_INTERACTIVE_FIRST_REPLY_MS ?? process.env.SKAKEL_INTERACTIVE_FIRST_REPLY_MS ?? 6e5);
|
|
346
|
+
return { firstReplyMs: Math.max(firstReplyMs, idleMs), idleMs };
|
|
347
|
+
}
|
|
348
|
+
function raceNextTurn(pending, idleMs, signal) {
|
|
349
|
+
return new Promise((resolve) => {
|
|
350
|
+
let settled = false;
|
|
351
|
+
let timer;
|
|
352
|
+
function finish(r) {
|
|
353
|
+
if (settled) return;
|
|
354
|
+
settled = true;
|
|
355
|
+
if (timer) clearTimeout(timer);
|
|
356
|
+
signal.removeEventListener("abort", onAbort);
|
|
357
|
+
resolve(r);
|
|
358
|
+
}
|
|
359
|
+
function onAbort() {
|
|
360
|
+
finish({ kind: "cancelled" });
|
|
361
|
+
}
|
|
362
|
+
if (signal.aborted) {
|
|
363
|
+
finish({ kind: "cancelled" });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
signal.addEventListener("abort", onAbort);
|
|
367
|
+
timer = setTimeout(() => finish({ kind: "idle-timeout" }), idleMs);
|
|
368
|
+
pending.then(
|
|
369
|
+
(res) => finish(res.done ? { kind: "turns-exhausted" } : { kind: "turn", value: res.value }),
|
|
370
|
+
() => finish({ kind: "turns-exhausted" })
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ../../packages/executor-worktree/src/stage-complete-tool.ts
|
|
376
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
377
|
+
import { z } from "zod";
|
|
378
|
+
var STAGE_COMPLETE_TOOL_NAME = "mcp__dahrk__dahrk_stage_complete";
|
|
379
|
+
function createStageCompleteTool() {
|
|
380
|
+
let captured = null;
|
|
381
|
+
let capturedDoc = null;
|
|
382
|
+
const completeTool = tool(
|
|
383
|
+
"dahrk_stage_complete",
|
|
384
|
+
"End the current stage and hand off a one-sentence summary of what was accomplished. When the stage's deliverable is a document (e.g. a specification or report) to be published, pass its full markdown body as `document`; this is the only way an interactive stage can emit a document, since it cannot write files.",
|
|
385
|
+
{
|
|
386
|
+
summary: z.string().describe("A one-sentence summary of the stage outcome."),
|
|
387
|
+
document: z.string().optional().describe("The full markdown body of the stage's deliverable document, if any.")
|
|
388
|
+
},
|
|
389
|
+
async (args) => {
|
|
390
|
+
captured = args.summary;
|
|
391
|
+
if (args.document !== void 0) capturedDoc = args.document;
|
|
392
|
+
return { content: [{ type: "text", text: "Stage marked complete." }] };
|
|
393
|
+
}
|
|
394
|
+
);
|
|
395
|
+
return {
|
|
396
|
+
server: createSdkMcpServer({ name: "dahrk", version: "0.0.0", tools: [completeTool] }),
|
|
397
|
+
allowedToolName: STAGE_COMPLETE_TOOL_NAME,
|
|
398
|
+
fired: () => captured !== null,
|
|
399
|
+
summary: () => captured,
|
|
400
|
+
document: () => capturedDoc
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ../../packages/executor-worktree/src/claude-adapter.ts
|
|
405
|
+
var COALESCE_MS = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
|
|
406
|
+
var MAX_TURNS = Number(process.env.DAHRK_MAX_TURNS ?? process.env.SKAKEL_MAX_TURNS ?? 64);
|
|
407
|
+
var HANDED_BACK_ARTIFACT_PATH = ".skakel/scratch/output/document.md";
|
|
408
|
+
var CLAUDE_CODE_SYSTEM_PROMPT = { type: "preset", preset: "claude_code" };
|
|
409
|
+
var userMsg = (text) => ({
|
|
410
|
+
type: "user",
|
|
411
|
+
parent_tool_use_id: null,
|
|
412
|
+
message: { role: "user", content: text }
|
|
413
|
+
});
|
|
414
|
+
var sessionIdOf = (msg) => "session_id" in msg && typeof msg.session_id === "string" ? msg.session_id : void 0;
|
|
415
|
+
function buildBrokeredMcpServers(ctx) {
|
|
416
|
+
const servers = ctx.config.mcpServers;
|
|
417
|
+
if (!servers || servers.length === 0 || !ctx.mcpProxyBaseUrl) return void 0;
|
|
418
|
+
const entries = {};
|
|
419
|
+
for (const s of servers) entries[s.id] = { type: s.type, url: `${ctx.mcpProxyBaseUrl}/${s.id}` };
|
|
420
|
+
return entries;
|
|
421
|
+
}
|
|
422
|
+
function createClaudeRunner() {
|
|
423
|
+
const abortController = new AbortController();
|
|
424
|
+
let cancelled = false;
|
|
425
|
+
let active;
|
|
426
|
+
let sessionId;
|
|
427
|
+
let costUsd;
|
|
428
|
+
const baseOptions = (ctx) => ({
|
|
429
|
+
cwd: ctx.workspace.worktreePath,
|
|
430
|
+
abortController,
|
|
431
|
+
// The deterministic push-stage commit sets the harness author identity itself (see
|
|
432
|
+
// GitService.commitAndPush); when the Claude runtime commits inside an interactive stage it must
|
|
433
|
+
// NOT append its own `Co-Authored-By: Claude <noreply@anthropic.com>` trailer. `includeCoAuthoredBy`
|
|
434
|
+
// is a Claude Code SETTINGS key (not a top-level Options field), so it rides the inline `settings`
|
|
435
|
+
// object, which sits at the flag layer and overrides any project/local setting. Mirrors the cyrus
|
|
436
|
+
// reference (EdgeWorker.ts writes the same key into .claude/settings.local.json).
|
|
437
|
+
settings: { includeCoAuthoredBy: false },
|
|
438
|
+
// Inherit the REPO's .mcp.json / .claude settings (build spec section 9): do NOT set
|
|
439
|
+
// strictMcpConfig. Policy enforcement around tools is M6; M4 allows tools to run and the
|
|
440
|
+
// stage runner intercepts denied actions at the trace level.
|
|
441
|
+
// Deliberately EXCLUDE "user": a stage must be hermetic to the worktree and not inherit the
|
|
442
|
+
// edge operator's ~/.claude (their CLAUDE.md, settings, memory), which is machine-specific and
|
|
443
|
+
// bleeds non-deterministic context across edges. "project"/"local" honour the repo's own
|
|
444
|
+
// .claude/ + CLAUDE.md + .mcp.json, which is all section 9 actually requires. Claude auth is
|
|
445
|
+
// keychain/OAuth and independent of settingSources, so dropping "user" does not affect it.
|
|
446
|
+
settingSources: ["project", "local"],
|
|
447
|
+
...ctx.config.model ? { model: ctx.config.model } : {},
|
|
448
|
+
...ctx.sessionId ? { resume: ctx.sessionId } : {},
|
|
449
|
+
...ctx.config.skill ? { skills: [ctx.config.skill] } : {}
|
|
450
|
+
});
|
|
451
|
+
const handleMessage = (msg, emit, ctx, state, suppressStageExit) => {
|
|
452
|
+
const found = sessionIdOf(msg);
|
|
453
|
+
if (found) sessionId = found;
|
|
454
|
+
if (msg.type === "result" && typeof msg.total_cost_usd === "number") costUsd = msg.total_cost_usd;
|
|
455
|
+
const rawRef = ctx.writeRaw?.(msg);
|
|
456
|
+
const res = consumeClaudeMessage(msg, state, suppressStageExit);
|
|
457
|
+
for (const e of res.events) emit(e, rawRef);
|
|
458
|
+
return { isResult: res.isResult, status: res.status, responseText: res.responseText };
|
|
459
|
+
};
|
|
460
|
+
const closeActive = () => {
|
|
461
|
+
try {
|
|
462
|
+
active?.close();
|
|
463
|
+
} catch {
|
|
464
|
+
}
|
|
465
|
+
active = void 0;
|
|
466
|
+
};
|
|
467
|
+
return {
|
|
468
|
+
runtime: "claude-code",
|
|
469
|
+
async runBatch(ctx, onTrace) {
|
|
470
|
+
const emit = makeEmit("claude-code", onTrace);
|
|
471
|
+
const prompt = resolveStagePrompt(ctx);
|
|
472
|
+
const mcpServers = buildBrokeredMcpServers(ctx);
|
|
473
|
+
const options = {
|
|
474
|
+
...baseOptions(ctx),
|
|
475
|
+
systemPrompt: CLAUDE_CODE_SYSTEM_PROMPT,
|
|
476
|
+
// Brokered MCP servers merged additively with the repo's .mcp.json (settingSources).
|
|
477
|
+
// Tools stay allowed by canUseTool below; we do NOT set a restrictive allowedTools here.
|
|
478
|
+
...mcpServers ? { mcpServers } : {},
|
|
479
|
+
// Headless: allow tools to run without an interactive permission prompt (M6 wires policy).
|
|
480
|
+
canUseTool: async (_toolName, input) => ({ behavior: "allow", updatedInput: input })
|
|
481
|
+
};
|
|
482
|
+
const state = newBufferState();
|
|
483
|
+
let status = "ok";
|
|
484
|
+
try {
|
|
485
|
+
const q = query({ prompt, options });
|
|
486
|
+
active = q;
|
|
487
|
+
for await (const msg of q) {
|
|
488
|
+
const res = handleMessage(msg, emit, ctx, state, false);
|
|
489
|
+
if (res.isResult && res.status) status = res.status;
|
|
490
|
+
}
|
|
491
|
+
} catch (e) {
|
|
492
|
+
if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
|
|
493
|
+
status = "fail";
|
|
494
|
+
} finally {
|
|
495
|
+
closeActive();
|
|
496
|
+
}
|
|
497
|
+
if (cancelled) status = "fail";
|
|
498
|
+
return { status, ...sessionId ? { sessionId } : {}, ...costUsd !== void 0 ? { costUsd } : {} };
|
|
499
|
+
},
|
|
500
|
+
async runInteractive(ctx, turns, onTrace) {
|
|
501
|
+
const emit = makeEmit("claude-code", onTrace);
|
|
502
|
+
const stageTool = createStageCompleteTool();
|
|
503
|
+
const brokered = buildBrokeredMcpServers(ctx);
|
|
504
|
+
let summarising = false;
|
|
505
|
+
const options = {
|
|
506
|
+
...baseOptions(ctx),
|
|
507
|
+
// Keep the cwd-anchoring preset; fold the stage instruction in via `append` so the
|
|
508
|
+
// interactive persona still gets it without losing the working-directory context.
|
|
509
|
+
systemPrompt: hasSystemPrompt(ctx) ? { type: "preset", preset: "claude_code", append: resolveStagePrompt(ctx) } : CLAUDE_CODE_SYSTEM_PROMPT,
|
|
510
|
+
// Inject the stage-complete exit tool alongside any brokered MCP servers (parity with batch).
|
|
511
|
+
mcpServers: { dahrk: stageTool.server, ...brokered ?? {} },
|
|
512
|
+
// Auto-approve the exit tool; `allowedTools` is an auto-approve list, not a whitelist, so it
|
|
513
|
+
// does not restrict the other tools canUseTool allows.
|
|
514
|
+
allowedTools: [stageTool.allowedToolName],
|
|
515
|
+
canUseTool: async (toolName, input) => summarising && toolName !== stageTool.allowedToolName ? { behavior: "deny", message: "Summarise from the work you just did; reply with the sentence only, no tools." } : { behavior: "allow", updatedInput: input },
|
|
516
|
+
maxTurns: MAX_TURNS,
|
|
517
|
+
includePartialMessages: false
|
|
518
|
+
};
|
|
519
|
+
const exit = ctx.config.exit ?? "gate";
|
|
520
|
+
const wantsTool = exit === "tool" || exit === "either";
|
|
521
|
+
const mailbox = new ManagedMailbox();
|
|
522
|
+
const q = query({ prompt: mailbox, options });
|
|
523
|
+
active = q;
|
|
524
|
+
const it = q[Symbol.asyncIterator]();
|
|
525
|
+
const humanIter = turns[Symbol.asyncIterator]();
|
|
526
|
+
const state = newBufferState();
|
|
527
|
+
const consumeTurn = async () => {
|
|
528
|
+
for (; ; ) {
|
|
529
|
+
const { value: msg, done } = await it.next();
|
|
530
|
+
if (done) return void 0;
|
|
531
|
+
const res = handleMessage(msg, emit, ctx, state, true);
|
|
532
|
+
if (res.isResult) return res.responseText;
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
|
|
536
|
+
let awaitingFirstReply = true;
|
|
537
|
+
let exited = null;
|
|
538
|
+
let pending = humanIter.next();
|
|
539
|
+
try {
|
|
540
|
+
mailbox.push(userMsg(interactiveSeedText(ctx, hasSystemPrompt(ctx))));
|
|
541
|
+
await consumeTurn();
|
|
542
|
+
if (stageTool.fired() && wantsTool) exited = "tool";
|
|
543
|
+
while (exited === null) {
|
|
544
|
+
const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, abortController.signal);
|
|
545
|
+
awaitingFirstReply = false;
|
|
546
|
+
if (race.kind === "cancelled") {
|
|
547
|
+
exited = "cancelled";
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
if (race.kind === "idle-timeout") {
|
|
551
|
+
exited = "timeout";
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
if (race.kind === "turns-exhausted") {
|
|
555
|
+
exited = "gate";
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
const texts = [race.value.text];
|
|
559
|
+
pending = humanIter.next();
|
|
560
|
+
for (; ; ) {
|
|
561
|
+
const more = await raceNextTurn(pending, COALESCE_MS, abortController.signal);
|
|
562
|
+
if (more.kind === "turn") {
|
|
563
|
+
texts.push(more.value.text);
|
|
564
|
+
pending = humanIter.next();
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (more.kind === "cancelled") exited = "cancelled";
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
if (exited === "cancelled") break;
|
|
571
|
+
mailbox.push(userMsg(texts.join("\n")));
|
|
572
|
+
await consumeTurn();
|
|
573
|
+
if (stageTool.fired() && wantsTool) {
|
|
574
|
+
exited = "tool";
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
} catch (e) {
|
|
579
|
+
if (!cancelled && !exited) emit({ type: "error", kind: "runtime_error", message: e.message });
|
|
580
|
+
exited = exited ?? (cancelled ? "cancelled" : "gate");
|
|
581
|
+
}
|
|
582
|
+
let status = "ok";
|
|
583
|
+
let summary = "";
|
|
584
|
+
if (exited === "tool") {
|
|
585
|
+
summary = stageTool.summary() ?? "(stage marked complete)";
|
|
586
|
+
} else if (exited === "gate") {
|
|
587
|
+
summarising = true;
|
|
588
|
+
try {
|
|
589
|
+
mailbox.push(userMsg(SUMMARISE_PROMPT));
|
|
590
|
+
const reply = await consumeTurn();
|
|
591
|
+
summary = (reply ?? "").trim() || "(no summary produced)";
|
|
592
|
+
} catch {
|
|
593
|
+
summary = "(no summary produced)";
|
|
594
|
+
} finally {
|
|
595
|
+
summarising = false;
|
|
596
|
+
}
|
|
597
|
+
} else if (exited === "timeout") {
|
|
598
|
+
status = "timeout";
|
|
599
|
+
summary = "(stage timed out awaiting input)";
|
|
600
|
+
await this.cancel();
|
|
601
|
+
} else {
|
|
602
|
+
status = "fail";
|
|
603
|
+
summary = "(stage cancelled)";
|
|
604
|
+
}
|
|
605
|
+
mailbox.end();
|
|
606
|
+
try {
|
|
607
|
+
for (; ; ) {
|
|
608
|
+
const { done } = await it.next();
|
|
609
|
+
if (done) break;
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
}
|
|
613
|
+
closeActive();
|
|
614
|
+
const handedBackDoc = status === "ok" ? stageTool.document() : null;
|
|
615
|
+
const artifact = handedBackDoc !== null ? { path: ctx.config.emitArtifact ?? HANDED_BACK_ARTIFACT_PATH, content: handedBackDoc } : void 0;
|
|
616
|
+
return {
|
|
617
|
+
status,
|
|
618
|
+
summary,
|
|
619
|
+
...sessionId ? { sessionId } : {},
|
|
620
|
+
...costUsd !== void 0 ? { costUsd } : {},
|
|
621
|
+
...artifact ? { artifact } : {}
|
|
622
|
+
};
|
|
623
|
+
},
|
|
624
|
+
async summarise(ctx) {
|
|
625
|
+
if (!sessionId) return "(no summary: session not established)";
|
|
626
|
+
const options = {
|
|
627
|
+
...baseOptions(ctx),
|
|
628
|
+
resume: sessionId,
|
|
629
|
+
maxTurns: 4,
|
|
630
|
+
canUseTool: async () => ({
|
|
631
|
+
behavior: "deny",
|
|
632
|
+
message: "Summarise from the work you just did; reply with the sentence only, no tools."
|
|
633
|
+
})
|
|
634
|
+
};
|
|
635
|
+
try {
|
|
636
|
+
let out = "";
|
|
637
|
+
const q = query({ prompt: SUMMARISE_PROMPT, options });
|
|
638
|
+
active = q;
|
|
639
|
+
for await (const msg of q) {
|
|
640
|
+
const found = sessionIdOf(msg);
|
|
641
|
+
if (found) sessionId = found;
|
|
642
|
+
if (msg.type === "result" && msg.subtype === "success") out += msg.result;
|
|
643
|
+
}
|
|
644
|
+
return out.trim() || "(no summary produced)";
|
|
645
|
+
} catch (e) {
|
|
646
|
+
return `(summary unavailable: ${e.message})`;
|
|
647
|
+
} finally {
|
|
648
|
+
closeActive();
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
async cancel() {
|
|
652
|
+
if (cancelled) return;
|
|
653
|
+
cancelled = true;
|
|
654
|
+
try {
|
|
655
|
+
await active?.interrupt();
|
|
656
|
+
} catch {
|
|
657
|
+
}
|
|
658
|
+
abortController.abort();
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ../../packages/executor-worktree/src/codex-adapter.ts
|
|
664
|
+
import { Codex } from "@openai/codex-sdk";
|
|
665
|
+
|
|
666
|
+
// ../../packages/executor-worktree/src/codex-mappers.ts
|
|
667
|
+
function mapItem(item) {
|
|
668
|
+
switch (item.type) {
|
|
669
|
+
case "reasoning":
|
|
670
|
+
return [{ type: "thought", subtype: "reasoning_text", text: item.text }];
|
|
671
|
+
case "agent_message":
|
|
672
|
+
return [{ type: "response", text: item.text }];
|
|
673
|
+
case "command_execution":
|
|
674
|
+
return [
|
|
675
|
+
{ type: "action", tool: "command", toolUseId: item.id, input: { command: item.command } },
|
|
676
|
+
{ type: "observation", toolUseId: item.id, output: item.aggregated_output, isError: item.status === "failed" }
|
|
677
|
+
];
|
|
678
|
+
case "mcp_tool_call":
|
|
679
|
+
return [
|
|
680
|
+
{ type: "action", tool: `${item.server}/${item.tool}`, toolUseId: item.id, input: item.arguments },
|
|
681
|
+
{ type: "observation", toolUseId: item.id, output: item.result?.content ?? item.error, isError: Boolean(item.error) }
|
|
682
|
+
];
|
|
683
|
+
case "web_search":
|
|
684
|
+
return [{ type: "action", tool: "web_search", toolUseId: item.id, input: { query: item.query } }];
|
|
685
|
+
case "file_change":
|
|
686
|
+
return [{ type: "action", tool: "apply_patch", toolUseId: item.id, input: item.changes }];
|
|
687
|
+
case "todo_list":
|
|
688
|
+
return [{ type: "thought", text: JSON.stringify(item.items) }];
|
|
689
|
+
case "error":
|
|
690
|
+
return [{ type: "error", kind: "item_error", message: item.message }];
|
|
691
|
+
default:
|
|
692
|
+
return [];
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function mapCodexEvent(ev) {
|
|
696
|
+
switch (ev.type) {
|
|
697
|
+
case "item.completed":
|
|
698
|
+
return { events: mapItem(ev.item), recognised: true };
|
|
699
|
+
case "turn.completed":
|
|
700
|
+
return {
|
|
701
|
+
events: [
|
|
702
|
+
{ type: "state", event: "stage-exit", status: "ok", usage: mapUsage2(ev.usage) }
|
|
703
|
+
],
|
|
704
|
+
recognised: true
|
|
705
|
+
};
|
|
706
|
+
case "turn.failed":
|
|
707
|
+
return {
|
|
708
|
+
events: [
|
|
709
|
+
{ type: "error", kind: "turn_failed", message: JSON.stringify(ev.error ?? {}) },
|
|
710
|
+
{ type: "state", event: "stage-exit", status: "fail" }
|
|
711
|
+
],
|
|
712
|
+
recognised: true
|
|
713
|
+
};
|
|
714
|
+
case "error":
|
|
715
|
+
return { events: [{ type: "error", kind: "thread_error", message: ev.message }], recognised: true };
|
|
716
|
+
// Lifecycle / interim updates: recognised, captured in raw sidecar, not normalised.
|
|
717
|
+
case "thread.started":
|
|
718
|
+
case "turn.started":
|
|
719
|
+
case "item.started":
|
|
720
|
+
case "item.updated":
|
|
721
|
+
return { events: [], recognised: true };
|
|
722
|
+
default:
|
|
723
|
+
return { events: [], recognised: false };
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function mapUsage2(u) {
|
|
727
|
+
return {
|
|
728
|
+
input: u?.input_tokens ?? 0,
|
|
729
|
+
output: u?.output_tokens ?? 0,
|
|
730
|
+
cacheRead: u?.cached_input_tokens ?? 0,
|
|
731
|
+
cacheCreate: 0
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ../../packages/executor-worktree/src/codex-adapter.ts
|
|
736
|
+
var COALESCE_MS2 = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
|
|
737
|
+
function createCodexRunner() {
|
|
738
|
+
const abortController = new AbortController();
|
|
739
|
+
const signal = abortController.signal;
|
|
740
|
+
let cancelled = false;
|
|
741
|
+
let thread;
|
|
742
|
+
let sessionId;
|
|
743
|
+
const threadOptions = (ctx) => ({
|
|
744
|
+
workingDirectory: ctx.workspace.worktreePath,
|
|
745
|
+
sandboxMode: "workspace-write",
|
|
746
|
+
skipGitRepoCheck: true,
|
|
747
|
+
...ctx.config.model ? { model: ctx.config.model } : {}
|
|
748
|
+
});
|
|
749
|
+
const openThread = (ctx) => {
|
|
750
|
+
const codex = new Codex();
|
|
751
|
+
const t = ctx.sessionId ? codex.resumeThread(ctx.sessionId, threadOptions(ctx)) : codex.startThread(threadOptions(ctx));
|
|
752
|
+
thread = t;
|
|
753
|
+
return t;
|
|
754
|
+
};
|
|
755
|
+
const pumpTurn = async (events, emit, ctx, suppressStageExit) => {
|
|
756
|
+
let failed = false;
|
|
757
|
+
for await (const ev of events) {
|
|
758
|
+
const rawRef = ctx.writeRaw?.(ev);
|
|
759
|
+
const { events: mapped } = mapCodexEvent(ev);
|
|
760
|
+
for (const e of mapped) {
|
|
761
|
+
if (suppressStageExit && e.type === "state") continue;
|
|
762
|
+
emit(e, rawRef);
|
|
763
|
+
}
|
|
764
|
+
if (ev.type === "thread.started") sessionId = ev.thread_id;
|
|
765
|
+
if (ev.type === "turn.failed") failed = true;
|
|
766
|
+
}
|
|
767
|
+
return failed;
|
|
768
|
+
};
|
|
769
|
+
const captureThreadId = (t) => {
|
|
770
|
+
if (t.id) sessionId = t.id;
|
|
771
|
+
};
|
|
772
|
+
return {
|
|
773
|
+
runtime: "codex",
|
|
774
|
+
async runBatch(ctx, onTrace) {
|
|
775
|
+
if (ctx.config.mcpServers && ctx.config.mcpServers.length > 0) {
|
|
776
|
+
process.stderr.write("codex-adapter: MCP servers not supported on Codex; ignoring\n");
|
|
777
|
+
}
|
|
778
|
+
const emit = makeEmit("codex", onTrace);
|
|
779
|
+
const t = openThread(ctx);
|
|
780
|
+
let status = "ok";
|
|
781
|
+
try {
|
|
782
|
+
const { events } = await t.runStreamed(resolveStagePrompt(ctx), { signal });
|
|
783
|
+
if (await pumpTurn(events, emit, ctx, false)) status = "fail";
|
|
784
|
+
} catch (e) {
|
|
785
|
+
if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
|
|
786
|
+
status = "fail";
|
|
787
|
+
}
|
|
788
|
+
captureThreadId(t);
|
|
789
|
+
if (cancelled) status = "fail";
|
|
790
|
+
return { status, ...sessionId ? { sessionId } : {} };
|
|
791
|
+
},
|
|
792
|
+
async runInteractive(ctx, turns, onTrace) {
|
|
793
|
+
const emit = makeEmit("codex", onTrace);
|
|
794
|
+
const t = openThread(ctx);
|
|
795
|
+
const exit = ctx.config.exit ?? "gate";
|
|
796
|
+
if (exit === "tool" || exit === "either") {
|
|
797
|
+
process.stderr.write("codex-adapter: interactive tool-exit not supported in M4; using gate exit\n");
|
|
798
|
+
}
|
|
799
|
+
const humanIter = turns[Symbol.asyncIterator]();
|
|
800
|
+
const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
|
|
801
|
+
let awaitingFirstReply = true;
|
|
802
|
+
let exited = "gate";
|
|
803
|
+
let pending = humanIter.next();
|
|
804
|
+
try {
|
|
805
|
+
const seed = await t.runStreamed(interactiveSeedText(ctx, false), { signal });
|
|
806
|
+
await pumpTurn(seed.events, emit, ctx, true);
|
|
807
|
+
for (; ; ) {
|
|
808
|
+
const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, signal);
|
|
809
|
+
awaitingFirstReply = false;
|
|
810
|
+
if (race.kind === "cancelled") {
|
|
811
|
+
exited = "cancelled";
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
if (race.kind === "idle-timeout") {
|
|
815
|
+
exited = "timeout";
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
if (race.kind === "turns-exhausted") {
|
|
819
|
+
exited = "gate";
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
const texts = [race.value.text];
|
|
823
|
+
pending = humanIter.next();
|
|
824
|
+
for (; ; ) {
|
|
825
|
+
const more = await raceNextTurn(pending, COALESCE_MS2, signal);
|
|
826
|
+
if (more.kind === "turn") {
|
|
827
|
+
texts.push(more.value.text);
|
|
828
|
+
pending = humanIter.next();
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
if (more.kind === "cancelled") exited = "cancelled";
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
if (exited === "cancelled") break;
|
|
835
|
+
const { events } = await t.runStreamed(texts.join("\n"), { signal });
|
|
836
|
+
await pumpTurn(events, emit, ctx, true);
|
|
837
|
+
}
|
|
838
|
+
} catch (e) {
|
|
839
|
+
if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
|
|
840
|
+
exited = cancelled ? "cancelled" : "gate";
|
|
841
|
+
}
|
|
842
|
+
let status = "ok";
|
|
843
|
+
let summary = "";
|
|
844
|
+
if (exited === "gate") {
|
|
845
|
+
try {
|
|
846
|
+
const turn = await t.run(SUMMARISE_PROMPT, { signal });
|
|
847
|
+
summary = (turn.finalResponse ?? "").trim() || "(no summary produced)";
|
|
848
|
+
} catch {
|
|
849
|
+
summary = "(no summary produced)";
|
|
850
|
+
}
|
|
851
|
+
} else if (exited === "timeout") {
|
|
852
|
+
status = "timeout";
|
|
853
|
+
summary = "(stage timed out awaiting input)";
|
|
854
|
+
await this.cancel();
|
|
855
|
+
} else {
|
|
856
|
+
status = "fail";
|
|
857
|
+
summary = "(stage cancelled)";
|
|
858
|
+
}
|
|
859
|
+
captureThreadId(t);
|
|
860
|
+
return { status, summary, ...sessionId ? { sessionId } : {} };
|
|
861
|
+
},
|
|
862
|
+
async summarise(ctx) {
|
|
863
|
+
if (!thread) return "(no summary: thread not established)";
|
|
864
|
+
try {
|
|
865
|
+
const turn = await thread.run(SUMMARISE_PROMPT, { signal });
|
|
866
|
+
captureThreadId(thread);
|
|
867
|
+
return (turn.finalResponse ?? "").trim() || "(no summary produced)";
|
|
868
|
+
} catch (e) {
|
|
869
|
+
return `(summary unavailable: ${e.message})`;
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
async cancel() {
|
|
873
|
+
if (cancelled) return;
|
|
874
|
+
cancelled = true;
|
|
875
|
+
abortController.abort();
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ../../packages/executor-worktree/src/pi-mappers.ts
|
|
881
|
+
function mapUsage3(u) {
|
|
882
|
+
return {
|
|
883
|
+
input: u?.input ?? 0,
|
|
884
|
+
output: u?.output ?? 0,
|
|
885
|
+
cacheRead: u?.cacheRead ?? 0,
|
|
886
|
+
cacheCreate: u?.cacheWrite ?? 0
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
function settleStatus(m) {
|
|
890
|
+
if (!m) return "ok";
|
|
891
|
+
if (m.errorMessage || m.stopReason === "error" || m.stopReason === "aborted") return "fail";
|
|
892
|
+
return "ok";
|
|
893
|
+
}
|
|
894
|
+
function settleMessage(ev) {
|
|
895
|
+
if (ev.type === "turn_end") return ev.message;
|
|
896
|
+
if (ev.type === "agent_end") return ev.messages?.[ev.messages.length - 1];
|
|
897
|
+
return void 0;
|
|
898
|
+
}
|
|
899
|
+
function mapPiEvent(ev) {
|
|
900
|
+
switch (ev.type) {
|
|
901
|
+
case "tool_execution_start":
|
|
902
|
+
return {
|
|
903
|
+
events: [{ type: "action", tool: ev.toolName, toolUseId: ev.toolCallId, input: ev.args }],
|
|
904
|
+
recognised: true
|
|
905
|
+
};
|
|
906
|
+
case "tool_execution_end":
|
|
907
|
+
return {
|
|
908
|
+
events: [
|
|
909
|
+
{ type: "observation", toolUseId: ev.toolCallId, output: ev.content, isError: Boolean(ev.isError) }
|
|
910
|
+
],
|
|
911
|
+
recognised: true
|
|
912
|
+
};
|
|
913
|
+
case "turn_end":
|
|
914
|
+
case "agent_end": {
|
|
915
|
+
const m = settleMessage(ev);
|
|
916
|
+
const status = settleStatus(m);
|
|
917
|
+
const events = [];
|
|
918
|
+
if (status !== "ok") {
|
|
919
|
+
const kind = ev.type === "agent_end" ? "agent_error" : "turn_error";
|
|
920
|
+
events.push({ type: "error", kind, message: m?.errorMessage ?? m?.stopReason ?? "failed" });
|
|
921
|
+
}
|
|
922
|
+
events.push({ type: "state", event: "stage-exit", status, usage: mapUsage3(m?.usage) });
|
|
923
|
+
return { events, recognised: true };
|
|
924
|
+
}
|
|
925
|
+
// Streamed deltas: owned by the buffered state machine, no discrete event here.
|
|
926
|
+
case "message_update":
|
|
927
|
+
// Lifecycle / interim noise: recognised, captured in the raw sidecar, not normalised.
|
|
928
|
+
case "message_start":
|
|
929
|
+
case "message_end":
|
|
930
|
+
case "agent_start":
|
|
931
|
+
case "turn_start":
|
|
932
|
+
case "tool_execution_update":
|
|
933
|
+
case "queue_update":
|
|
934
|
+
case "compaction_start":
|
|
935
|
+
case "compaction_end":
|
|
936
|
+
case "auto_retry_start":
|
|
937
|
+
case "auto_retry_end":
|
|
938
|
+
return { events: [], recognised: true };
|
|
939
|
+
default:
|
|
940
|
+
return { events: [], recognised: false };
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
var newPiBufferState = () => ({
|
|
944
|
+
bufferedText: "",
|
|
945
|
+
pendingThought: "",
|
|
946
|
+
turnEndedOnTool: false
|
|
947
|
+
});
|
|
948
|
+
function consumePiEvent(ev, state, suppressStageExit) {
|
|
949
|
+
const events = [];
|
|
950
|
+
if (ev.type === "message_update") {
|
|
951
|
+
const ame = ev.assistantMessageEvent;
|
|
952
|
+
if (ame.type === "text_delta") {
|
|
953
|
+
state.bufferedText += ame.delta ?? "";
|
|
954
|
+
state.turnEndedOnTool = false;
|
|
955
|
+
} else if (ame.type === "thinking_delta") {
|
|
956
|
+
state.pendingThought += ame.delta ?? "";
|
|
957
|
+
}
|
|
958
|
+
return { events, isResult: false };
|
|
959
|
+
}
|
|
960
|
+
if (ev.type === "tool_execution_start") {
|
|
961
|
+
if (state.pendingThought) {
|
|
962
|
+
events.push({ type: "thought", subtype: "reasoning_text", text: state.pendingThought });
|
|
963
|
+
state.pendingThought = "";
|
|
964
|
+
}
|
|
965
|
+
events.push(...mapPiEvent(ev).events);
|
|
966
|
+
state.turnEndedOnTool = true;
|
|
967
|
+
state.bufferedText = "";
|
|
968
|
+
return { events, isResult: false };
|
|
969
|
+
}
|
|
970
|
+
if (ev.type === "tool_execution_end") {
|
|
971
|
+
events.push(...mapPiEvent(ev).events);
|
|
972
|
+
return { events, isResult: false };
|
|
973
|
+
}
|
|
974
|
+
if (ev.type === "turn_end" || ev.type === "agent_end") {
|
|
975
|
+
const r = mapPiEvent(ev);
|
|
976
|
+
const status = settleStatus(settleMessage(ev));
|
|
977
|
+
let responseText;
|
|
978
|
+
const text = state.bufferedText.trim();
|
|
979
|
+
if (status === "ok" && text && !state.turnEndedOnTool) {
|
|
980
|
+
responseText = text;
|
|
981
|
+
events.push({ type: "response", text });
|
|
982
|
+
}
|
|
983
|
+
if (state.pendingThought) {
|
|
984
|
+
events.push({ type: "thought", subtype: "reasoning_text", text: state.pendingThought });
|
|
985
|
+
}
|
|
986
|
+
for (const e of r.events) {
|
|
987
|
+
if (suppressStageExit && e.type === "state") continue;
|
|
988
|
+
events.push(e);
|
|
989
|
+
}
|
|
990
|
+
state.bufferedText = "";
|
|
991
|
+
state.pendingThought = "";
|
|
992
|
+
state.turnEndedOnTool = false;
|
|
993
|
+
return { events, isResult: true, status, responseText };
|
|
994
|
+
}
|
|
995
|
+
return { events, isResult: false };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ../../packages/executor-worktree/src/pi-adapter.ts
|
|
999
|
+
var COALESCE_MS3 = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
|
|
1000
|
+
var PI_STAGE_COMPLETE_TOOL = "dahrk_stage_complete";
|
|
1001
|
+
function createPiRunner(deps = {}) {
|
|
1002
|
+
const createSession = deps.createSession ?? defaultCreatePiSession;
|
|
1003
|
+
const abortController = new AbortController();
|
|
1004
|
+
const signal = abortController.signal;
|
|
1005
|
+
let cancelled = false;
|
|
1006
|
+
let session;
|
|
1007
|
+
let sessionId;
|
|
1008
|
+
const openSession = async (ctx) => {
|
|
1009
|
+
if (!session) session = await createSession(ctx);
|
|
1010
|
+
return session;
|
|
1011
|
+
};
|
|
1012
|
+
const captureSessionId = (s) => {
|
|
1013
|
+
if (s.sessionId) sessionId = s.sessionId;
|
|
1014
|
+
};
|
|
1015
|
+
return {
|
|
1016
|
+
runtime: "pi",
|
|
1017
|
+
async runBatch(ctx, onTrace) {
|
|
1018
|
+
const emit = makeEmit("pi", onTrace);
|
|
1019
|
+
const s = await openSession(ctx);
|
|
1020
|
+
const state = newPiBufferState();
|
|
1021
|
+
let status = "ok";
|
|
1022
|
+
const unsub = s.subscribe((ev) => {
|
|
1023
|
+
const rawRef = ctx.writeRaw?.(ev);
|
|
1024
|
+
const r = consumePiEvent(ev, state, false);
|
|
1025
|
+
for (const e of r.events) emit(e, rawRef);
|
|
1026
|
+
if (r.isResult && r.status) status = r.status;
|
|
1027
|
+
});
|
|
1028
|
+
try {
|
|
1029
|
+
await s.prompt(resolveStagePrompt(ctx));
|
|
1030
|
+
} catch (e) {
|
|
1031
|
+
if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
|
|
1032
|
+
status = "fail";
|
|
1033
|
+
} finally {
|
|
1034
|
+
unsub();
|
|
1035
|
+
}
|
|
1036
|
+
captureSessionId(s);
|
|
1037
|
+
if (cancelled) status = "fail";
|
|
1038
|
+
return { status, ...sessionId ? { sessionId } : {} };
|
|
1039
|
+
},
|
|
1040
|
+
async runInteractive(ctx, turns, onTrace) {
|
|
1041
|
+
const emit = makeEmit("pi", onTrace);
|
|
1042
|
+
const s = await openSession(ctx);
|
|
1043
|
+
const state = newPiBufferState();
|
|
1044
|
+
const exit = ctx.config.exit ?? "gate";
|
|
1045
|
+
const wantsTool = exit === "tool" || exit === "either";
|
|
1046
|
+
let toolFired = false;
|
|
1047
|
+
let toolSummary = null;
|
|
1048
|
+
let stageCompleteCallId;
|
|
1049
|
+
let lastResponseText;
|
|
1050
|
+
const unsub = s.subscribe((ev) => {
|
|
1051
|
+
const rawRef = ctx.writeRaw?.(ev);
|
|
1052
|
+
if (ev.type === "tool_execution_start" && ev.toolName === PI_STAGE_COMPLETE_TOOL) {
|
|
1053
|
+
toolFired = true;
|
|
1054
|
+
stageCompleteCallId = ev.toolCallId;
|
|
1055
|
+
const args = ev.args;
|
|
1056
|
+
if (args?.summary) toolSummary = args.summary;
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (ev.type === "tool_execution_end" && ev.toolCallId === stageCompleteCallId) return;
|
|
1060
|
+
const r = consumePiEvent(ev, state, true);
|
|
1061
|
+
for (const e of r.events) emit(e, rawRef);
|
|
1062
|
+
if (r.responseText) lastResponseText = r.responseText;
|
|
1063
|
+
});
|
|
1064
|
+
const humanIter = turns[Symbol.asyncIterator]();
|
|
1065
|
+
const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
|
|
1066
|
+
let awaitingFirstReply = true;
|
|
1067
|
+
let exited = "gate";
|
|
1068
|
+
let pending = humanIter.next();
|
|
1069
|
+
try {
|
|
1070
|
+
await s.prompt(interactiveSeedText(ctx, false));
|
|
1071
|
+
if (toolFired && wantsTool) exited = "tool";
|
|
1072
|
+
for (; ; ) {
|
|
1073
|
+
if (exited === "tool") break;
|
|
1074
|
+
const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, signal);
|
|
1075
|
+
awaitingFirstReply = false;
|
|
1076
|
+
if (race.kind === "cancelled") {
|
|
1077
|
+
exited = "cancelled";
|
|
1078
|
+
break;
|
|
1079
|
+
}
|
|
1080
|
+
if (race.kind === "idle-timeout") {
|
|
1081
|
+
exited = "timeout";
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
if (race.kind === "turns-exhausted") {
|
|
1085
|
+
exited = "gate";
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
const texts = [race.value.text];
|
|
1089
|
+
pending = humanIter.next();
|
|
1090
|
+
for (; ; ) {
|
|
1091
|
+
const more = await raceNextTurn(pending, COALESCE_MS3, signal);
|
|
1092
|
+
if (more.kind === "turn") {
|
|
1093
|
+
texts.push(more.value.text);
|
|
1094
|
+
pending = humanIter.next();
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
if (more.kind === "cancelled") exited = "cancelled";
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
if (exited === "cancelled") break;
|
|
1101
|
+
await s.prompt(texts.join("\n"));
|
|
1102
|
+
if (toolFired && wantsTool) {
|
|
1103
|
+
exited = "tool";
|
|
1104
|
+
break;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
} catch (e) {
|
|
1108
|
+
if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
|
|
1109
|
+
exited = cancelled ? "cancelled" : "gate";
|
|
1110
|
+
}
|
|
1111
|
+
let status = "ok";
|
|
1112
|
+
let summary = "";
|
|
1113
|
+
if (exited === "tool") {
|
|
1114
|
+
summary = toolSummary ?? "(stage marked complete)";
|
|
1115
|
+
} else if (exited === "gate") {
|
|
1116
|
+
lastResponseText = void 0;
|
|
1117
|
+
try {
|
|
1118
|
+
await s.prompt(SUMMARISE_PROMPT);
|
|
1119
|
+
summary = (lastResponseText ?? "").trim() || "(no summary produced)";
|
|
1120
|
+
} catch {
|
|
1121
|
+
summary = "(no summary produced)";
|
|
1122
|
+
}
|
|
1123
|
+
} else if (exited === "timeout") {
|
|
1124
|
+
status = "timeout";
|
|
1125
|
+
summary = "(stage timed out awaiting input)";
|
|
1126
|
+
await this.cancel();
|
|
1127
|
+
} else {
|
|
1128
|
+
status = "fail";
|
|
1129
|
+
summary = "(stage cancelled)";
|
|
1130
|
+
}
|
|
1131
|
+
unsub();
|
|
1132
|
+
captureSessionId(s);
|
|
1133
|
+
return { status, summary, ...sessionId ? { sessionId } : {} };
|
|
1134
|
+
},
|
|
1135
|
+
async summarise(ctx) {
|
|
1136
|
+
if (!session) return "(no summary: session not established)";
|
|
1137
|
+
const s = session;
|
|
1138
|
+
if (s.agent) s.agent.state.tools = [];
|
|
1139
|
+
const state = newPiBufferState();
|
|
1140
|
+
let out;
|
|
1141
|
+
const unsub = s.subscribe((ev) => {
|
|
1142
|
+
const r = consumePiEvent(ev, state, true);
|
|
1143
|
+
if (r.responseText) out = r.responseText;
|
|
1144
|
+
});
|
|
1145
|
+
try {
|
|
1146
|
+
await s.prompt(SUMMARISE_PROMPT);
|
|
1147
|
+
captureSessionId(s);
|
|
1148
|
+
return (out ?? "").trim() || "(no summary produced)";
|
|
1149
|
+
} catch (e) {
|
|
1150
|
+
return `(summary unavailable: ${e.message})`;
|
|
1151
|
+
} finally {
|
|
1152
|
+
unsub();
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
async cancel() {
|
|
1156
|
+
if (cancelled) return;
|
|
1157
|
+
cancelled = true;
|
|
1158
|
+
abortController.abort();
|
|
1159
|
+
try {
|
|
1160
|
+
await session?.abort();
|
|
1161
|
+
} catch {
|
|
1162
|
+
}
|
|
1163
|
+
try {
|
|
1164
|
+
session?.dispose();
|
|
1165
|
+
} catch {
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
async function defaultCreatePiSession(ctx) {
|
|
1171
|
+
const spec = "@earendil-works/pi-coding-agent";
|
|
1172
|
+
const mod = await import(spec);
|
|
1173
|
+
const { AuthStorage, ModelRegistry, SessionManager, createAgentSession, defineTool, resolveCliModel } = mod;
|
|
1174
|
+
const authStorage = AuthStorage.create();
|
|
1175
|
+
for (const [key, value] of Object.entries(ctx.runtimeEnv ?? {})) {
|
|
1176
|
+
const provider = PROVIDER_BY_ENV[key];
|
|
1177
|
+
if (provider) authStorage.setRuntimeApiKey(provider, value);
|
|
1178
|
+
}
|
|
1179
|
+
const modelRegistry = ModelRegistry.create(authStorage);
|
|
1180
|
+
let model;
|
|
1181
|
+
if (ctx.config.model) {
|
|
1182
|
+
const resolved = resolveCliModel({ cliModel: ctx.config.model, modelRegistry });
|
|
1183
|
+
if (!resolved?.error) model = resolved?.model;
|
|
1184
|
+
}
|
|
1185
|
+
const stageComplete = defineTool({
|
|
1186
|
+
name: PI_STAGE_COMPLETE_TOOL,
|
|
1187
|
+
label: "Stage complete",
|
|
1188
|
+
description: "End the current stage and hand off a one-sentence summary of what was accomplished.",
|
|
1189
|
+
parameters: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
|
|
1190
|
+
execute: async () => ({ content: [{ type: "text", text: "Stage marked complete." }], details: {} })
|
|
1191
|
+
});
|
|
1192
|
+
const { session } = await createAgentSession({
|
|
1193
|
+
sessionManager: SessionManager.inMemory(ctx.workspace.worktreePath),
|
|
1194
|
+
authStorage,
|
|
1195
|
+
modelRegistry,
|
|
1196
|
+
cwd: ctx.workspace.worktreePath,
|
|
1197
|
+
customTools: [stageComplete],
|
|
1198
|
+
...model ? { model } : {}
|
|
1199
|
+
});
|
|
1200
|
+
return session;
|
|
1201
|
+
}
|
|
1202
|
+
var PROVIDER_BY_ENV = {
|
|
1203
|
+
ANTHROPIC_API_KEY: "anthropic",
|
|
1204
|
+
OPENAI_API_KEY: "openai",
|
|
1205
|
+
GEMINI_API_KEY: "google",
|
|
1206
|
+
GOOGLE_API_KEY: "google"
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
// ../../packages/executor-worktree/src/git-service.ts
|
|
1210
|
+
import { execFileSync } from "child_process";
|
|
1211
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
|
|
1212
|
+
import { homedir, tmpdir } from "os";
|
|
1213
|
+
import { dirname, isAbsolute, join as join2 } from "path";
|
|
1214
|
+
var noopLogger = { info: () => {
|
|
1215
|
+
}, warn: () => {
|
|
1216
|
+
} };
|
|
1217
|
+
function parseOwnerRepo(gitUrl) {
|
|
1218
|
+
const m = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/.exec(gitUrl.trim());
|
|
1219
|
+
return m ? `${m[1]}/${m[2]}` : void 0;
|
|
1220
|
+
}
|
|
1221
|
+
function sanitizeBranchName(name) {
|
|
1222
|
+
if (!name) return name;
|
|
1223
|
+
return name.replace(/[`~^:?*[\]\\@{}\s]/g, "-").replace(/\.{2,}/g, ".").replace(/\/{2,}/g, "/").replace(/\.lock(\/|$)/g, "$1").replace(/^[.\-/]+/, "").replace(/[.\-/]+$/, "").replace(/-{2,}/g, "-");
|
|
1224
|
+
}
|
|
1225
|
+
function createGitService(opts = {}) {
|
|
1226
|
+
const worktreesDir = opts.worktreesDir ?? process.env.DAHRK_WORKTREES_DIR ?? process.env.SKAKEL_WORKTREES_DIR ?? join2(homedir(), ".dahrk", "worktrees");
|
|
1227
|
+
const mirrorsDir = opts.mirrorsDir ?? process.env.DAHRK_MIRRORS_DIR ?? process.env.SKAKEL_MIRRORS_DIR ?? join2(homedir(), ".dahrk", "mirrors");
|
|
1228
|
+
const authorName = opts.authorName ?? process.env.DAHRK_GIT_AUTHOR_NAME ?? "Dahrk";
|
|
1229
|
+
const authorEmail = opts.authorEmail ?? process.env.DAHRK_GIT_AUTHOR_EMAIL ?? "noreply@dahrk.net";
|
|
1230
|
+
const log2 = opts.logger ?? noopLogger;
|
|
1231
|
+
const git = (cwd, args, env) => execFileSync("git", args, {
|
|
1232
|
+
cwd,
|
|
1233
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1234
|
+
encoding: "utf-8",
|
|
1235
|
+
...env ? { env } : {}
|
|
1236
|
+
});
|
|
1237
|
+
const gitBare = (args, env) => execFileSync("git", args, {
|
|
1238
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1239
|
+
encoding: "utf-8",
|
|
1240
|
+
...env ? { env } : {}
|
|
1241
|
+
});
|
|
1242
|
+
const gitOk = (cwd, args) => {
|
|
1243
|
+
try {
|
|
1244
|
+
git(cwd, args);
|
|
1245
|
+
return true;
|
|
1246
|
+
} catch {
|
|
1247
|
+
return false;
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
const gh = (cwd, args) => execFileSync("gh", args, { cwd, stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8" });
|
|
1251
|
+
const ghError = (e) => {
|
|
1252
|
+
const err = e;
|
|
1253
|
+
if (err?.code === "ENOENT") return "gh CLI not installed on this node";
|
|
1254
|
+
const stderr = typeof err?.stderr === "string" ? err.stderr : err?.stderr?.toString();
|
|
1255
|
+
return (stderr?.trim() || err?.message || String(e)).split("\n")[0] ?? String(e);
|
|
1256
|
+
};
|
|
1257
|
+
const excludeScratchLocally = (worktreePath) => {
|
|
1258
|
+
const entry = ".skakel/scratch/";
|
|
1259
|
+
try {
|
|
1260
|
+
const rel = git(worktreePath, ["rev-parse", "--git-path", "info/exclude"]).trim();
|
|
1261
|
+
const excludePath = isAbsolute(rel) ? rel : join2(worktreePath, rel);
|
|
1262
|
+
const existing = existsSync(excludePath) ? readFileSync2(excludePath, "utf-8") : "";
|
|
1263
|
+
if (existing.split("\n").some((l) => l.trim() === entry)) return;
|
|
1264
|
+
mkdirSync(dirname(excludePath), { recursive: true });
|
|
1265
|
+
const sep2 = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
1266
|
+
writeFileSync(excludePath, `${existing}${sep2}${entry}
|
|
1267
|
+
`);
|
|
1268
|
+
} catch (e) {
|
|
1269
|
+
log2.warn(`could not set worktree scratch exclude at ${worktreePath}: ${e.message}`);
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
const setupAuth = (token) => {
|
|
1273
|
+
const dir = mkdtempSync(join2(tmpdir(), "dahrk-cred-"));
|
|
1274
|
+
const script = join2(dir, "askpass.sh");
|
|
1275
|
+
writeFileSync(script, '#!/bin/sh\nprintf "%s" "$DAHRK_GIT_TOKEN"\n', { mode: 448 });
|
|
1276
|
+
return {
|
|
1277
|
+
env: { ...process.env, GIT_ASKPASS: script, DAHRK_GIT_TOKEN: token, GIT_TERMINAL_PROMPT: "0" },
|
|
1278
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true })
|
|
1279
|
+
};
|
|
1280
|
+
};
|
|
1281
|
+
const netEnv = (authEnv) => authEnv ?? { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
1282
|
+
const withTokenUser = (gitUrl) => /^https:\/\/[^@/]+@/.test(gitUrl) ? gitUrl : gitUrl.replace(/^https:\/\//, "https://x-access-token@");
|
|
1283
|
+
const mirrorPathFor = (repoId) => join2(mirrorsDir, sanitizeBranchName(repoId));
|
|
1284
|
+
const ensureMirror = (repoId, gitUrl, authEnv) => {
|
|
1285
|
+
const mirror = mirrorPathFor(repoId);
|
|
1286
|
+
if (existsSync(mirror) && gitOk(mirror, ["rev-parse", "--git-dir"])) {
|
|
1287
|
+
log2.info(`refreshing mirror ${repoId}`);
|
|
1288
|
+
try {
|
|
1289
|
+
git(mirror, ["remote", "update", "--prune"], netEnv(authEnv));
|
|
1290
|
+
return { mirror, refreshed: true };
|
|
1291
|
+
} catch (e) {
|
|
1292
|
+
log2.warn(`mirror remote update failed for ${repoId}: ${e.message}`);
|
|
1293
|
+
return { mirror, refreshed: false };
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
mkdirSync(mirrorsDir, { recursive: true });
|
|
1297
|
+
const cloneUrl = authEnv ? withTokenUser(gitUrl) : gitUrl;
|
|
1298
|
+
log2.info(`cloning mirror ${repoId} from ${gitUrl}`);
|
|
1299
|
+
gitBare(["clone", "--mirror", cloneUrl, mirror], netEnv(authEnv));
|
|
1300
|
+
return { mirror, refreshed: true };
|
|
1301
|
+
};
|
|
1302
|
+
const refFor = (spec, worktreePath) => ({
|
|
1303
|
+
repoId: spec.repoId,
|
|
1304
|
+
gitUrl: spec.gitUrl,
|
|
1305
|
+
repo: spec.repo ?? spec.repoId,
|
|
1306
|
+
baseBranch: spec.baseBranch,
|
|
1307
|
+
worktreePath,
|
|
1308
|
+
scratchPath: join2(worktreePath, ".skakel", "scratch")
|
|
1309
|
+
});
|
|
1310
|
+
return {
|
|
1311
|
+
async createWorktree(spec) {
|
|
1312
|
+
const { repoId, gitUrl, baseBranch, runId } = spec;
|
|
1313
|
+
const branchName = sanitizeBranchName(spec.branch ?? `dahrk/${runId}`);
|
|
1314
|
+
const worktreePath = join2(worktreesDir, runId);
|
|
1315
|
+
mkdirSync(worktreesDir, { recursive: true });
|
|
1316
|
+
if (existsSync(worktreePath) && gitOk(worktreePath, ["rev-parse", "--git-dir"])) {
|
|
1317
|
+
log2.info(`reusing existing worktree at ${worktreePath}`);
|
|
1318
|
+
mkdirSync(join2(worktreePath, ".skakel", "scratch"), { recursive: true });
|
|
1319
|
+
return refFor(spec, worktreePath);
|
|
1320
|
+
}
|
|
1321
|
+
const auth = spec.credentialToken ? setupAuth(spec.credentialToken) : void 0;
|
|
1322
|
+
try {
|
|
1323
|
+
const { mirror, refreshed } = ensureMirror(repoId, gitUrl, auth?.env);
|
|
1324
|
+
if (gitOk(mirror, ["rev-parse", "--verify", branchName])) {
|
|
1325
|
+
git(mirror, ["worktree", "add", "--force", worktreePath, branchName]);
|
|
1326
|
+
} else {
|
|
1327
|
+
if (!refreshed) {
|
|
1328
|
+
log2.info(`mirror refresh failed; fetching base ${baseBranch} before branching ${branchName}`);
|
|
1329
|
+
git(mirror, ["fetch", "origin", `+refs/heads/${baseBranch}:refs/heads/${baseBranch}`], netEnv(auth?.env));
|
|
1330
|
+
}
|
|
1331
|
+
log2.info(`creating worktree at ${worktreePath} from ${baseBranch} on ${branchName}`);
|
|
1332
|
+
git(mirror, ["worktree", "add", "-b", branchName, worktreePath, baseBranch]);
|
|
1333
|
+
}
|
|
1334
|
+
} finally {
|
|
1335
|
+
auth?.cleanup();
|
|
1336
|
+
}
|
|
1337
|
+
mkdirSync(join2(worktreePath, ".skakel", "scratch"), { recursive: true });
|
|
1338
|
+
return refFor(spec, worktreePath);
|
|
1339
|
+
},
|
|
1340
|
+
async commitAndPush(ref, opts2) {
|
|
1341
|
+
const { worktreePath } = ref;
|
|
1342
|
+
if (!existsSync(worktreePath) || !gitOk(worktreePath, ["rev-parse", "--git-dir"])) {
|
|
1343
|
+
throw new Error(`worktree missing for push: ${worktreePath}`);
|
|
1344
|
+
}
|
|
1345
|
+
const branch = sanitizeBranchName(opts2.branch);
|
|
1346
|
+
const SCRATCH = ".skakel/scratch";
|
|
1347
|
+
excludeScratchLocally(worktreePath);
|
|
1348
|
+
git(worktreePath, ["rm", "-r", "--cached", "--ignore-unmatch", "--quiet", SCRATCH]);
|
|
1349
|
+
git(worktreePath, ["add", "-A", "--", "."]);
|
|
1350
|
+
const hasStaged = !gitOk(worktreePath, ["diff", "--cached", "--quiet"]);
|
|
1351
|
+
if (hasStaged) {
|
|
1352
|
+
git(worktreePath, [
|
|
1353
|
+
"-c",
|
|
1354
|
+
`user.name=${authorName}`,
|
|
1355
|
+
"-c",
|
|
1356
|
+
`user.email=${authorEmail}`,
|
|
1357
|
+
"commit",
|
|
1358
|
+
"-m",
|
|
1359
|
+
opts2.message
|
|
1360
|
+
]);
|
|
1361
|
+
}
|
|
1362
|
+
const dirty = hasStaged;
|
|
1363
|
+
let headSha = git(worktreePath, ["rev-parse", "HEAD"]).trim();
|
|
1364
|
+
let commitsAhead = 0;
|
|
1365
|
+
for (const baseRef of [opts2.base, `origin/${opts2.base}`, `refs/heads/${opts2.base}`]) {
|
|
1366
|
+
if (!baseRef) continue;
|
|
1367
|
+
try {
|
|
1368
|
+
commitsAhead = Number.parseInt(git(worktreePath, ["rev-list", "--count", `${baseRef}..HEAD`]).trim(), 10) || 0;
|
|
1369
|
+
break;
|
|
1370
|
+
} catch {
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
const auth = opts2.credentialToken ? setupAuth(opts2.credentialToken) : void 0;
|
|
1374
|
+
const remote = opts2.credentialToken ? withTokenUser(ref.gitUrl) : ref.gitUrl;
|
|
1375
|
+
let pushed = false;
|
|
1376
|
+
let integration;
|
|
1377
|
+
try {
|
|
1378
|
+
let fetched = false;
|
|
1379
|
+
if (opts2.base) {
|
|
1380
|
+
try {
|
|
1381
|
+
git(worktreePath, ["fetch", remote, opts2.base], netEnv(auth?.env));
|
|
1382
|
+
fetched = true;
|
|
1383
|
+
} catch (e) {
|
|
1384
|
+
log2.warn(`base fetch failed for ${opts2.base}; skipping push-time integration: ${e.message}`);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
if (fetched) {
|
|
1388
|
+
try {
|
|
1389
|
+
git(worktreePath, [
|
|
1390
|
+
"-c",
|
|
1391
|
+
`user.name=${authorName}`,
|
|
1392
|
+
"-c",
|
|
1393
|
+
`user.email=${authorEmail}`,
|
|
1394
|
+
"merge",
|
|
1395
|
+
"--no-edit",
|
|
1396
|
+
"FETCH_HEAD"
|
|
1397
|
+
], auth?.env);
|
|
1398
|
+
integration = "clean";
|
|
1399
|
+
headSha = git(worktreePath, ["rev-parse", "HEAD"]).trim();
|
|
1400
|
+
} catch {
|
|
1401
|
+
const conflictFiles = git(worktreePath, ["diff", "--name-only", "--diff-filter=U"]).split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1402
|
+
git(worktreePath, ["merge", "--abort"]);
|
|
1403
|
+
return { headSha, pushed: false, nothingToCommit: !dirty, commitsAhead, integration: "conflict", conflictFiles };
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
git(worktreePath, ["push", remote, `HEAD:refs/heads/${branch}`], netEnv(auth?.env));
|
|
1407
|
+
pushed = true;
|
|
1408
|
+
} finally {
|
|
1409
|
+
auth?.cleanup();
|
|
1410
|
+
}
|
|
1411
|
+
return { headSha, pushed, nothingToCommit: !dirty, commitsAhead, ...integration ? { integration } : {} };
|
|
1412
|
+
},
|
|
1413
|
+
async openPrAmbient(ref, opts2) {
|
|
1414
|
+
const ownerRepo = parseOwnerRepo(ref.gitUrl);
|
|
1415
|
+
if (!ownerRepo) return { prError: `cannot derive owner/repo from ${ref.gitUrl}` };
|
|
1416
|
+
const { worktreePath } = ref;
|
|
1417
|
+
const branch = sanitizeBranchName(opts2.branch);
|
|
1418
|
+
const repoArgs = ["--repo", ownerRepo];
|
|
1419
|
+
const readBack = () => {
|
|
1420
|
+
try {
|
|
1421
|
+
const pr = JSON.parse(gh(worktreePath, ["pr", "view", branch, ...repoArgs, "--json", "number,url"]));
|
|
1422
|
+
return pr.url ? { prUrl: pr.url, ...pr.number !== void 0 ? { prNumber: pr.number } : {} } : void 0;
|
|
1423
|
+
} catch {
|
|
1424
|
+
return void 0;
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
const tmp = mkdtempSync(join2(tmpdir(), "dahrk-pr-"));
|
|
1428
|
+
const bodyFile = join2(tmp, "body.md");
|
|
1429
|
+
try {
|
|
1430
|
+
writeFileSync(bodyFile, opts2.body ?? "");
|
|
1431
|
+
try {
|
|
1432
|
+
gh(worktreePath, [
|
|
1433
|
+
"pr",
|
|
1434
|
+
"create",
|
|
1435
|
+
...repoArgs,
|
|
1436
|
+
"--head",
|
|
1437
|
+
branch,
|
|
1438
|
+
"--base",
|
|
1439
|
+
opts2.base,
|
|
1440
|
+
"--title",
|
|
1441
|
+
opts2.title,
|
|
1442
|
+
"--body-file",
|
|
1443
|
+
bodyFile
|
|
1444
|
+
]);
|
|
1445
|
+
} catch (e) {
|
|
1446
|
+
const existing = readBack();
|
|
1447
|
+
if (existing) return existing;
|
|
1448
|
+
return { prError: ghError(e) };
|
|
1449
|
+
}
|
|
1450
|
+
return readBack() ?? { prError: "gh pr create reported success but no PR was found" };
|
|
1451
|
+
} finally {
|
|
1452
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
1453
|
+
}
|
|
1454
|
+
},
|
|
1455
|
+
async teardownWorktree(ref) {
|
|
1456
|
+
if (!existsSync(ref.worktreePath)) return;
|
|
1457
|
+
const mirror = mirrorPathFor(ref.repoId);
|
|
1458
|
+
try {
|
|
1459
|
+
git(mirror, ["worktree", "remove", "--force", ref.worktreePath]);
|
|
1460
|
+
} catch (e) {
|
|
1461
|
+
log2.warn(`git worktree remove failed for ${ref.worktreePath}: ${e.message}`);
|
|
1462
|
+
}
|
|
1463
|
+
rmSync(ref.worktreePath, { recursive: true, force: true });
|
|
1464
|
+
try {
|
|
1465
|
+
git(mirror, ["worktree", "prune"]);
|
|
1466
|
+
} catch {
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// ../../packages/executor-worktree/src/trace-writer.ts
|
|
1473
|
+
import { createHash } from "crypto";
|
|
1474
|
+
import { appendFileSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1475
|
+
import { join as join3 } from "path";
|
|
1476
|
+
var DEFAULT_SPILL_BYTES = 8192;
|
|
1477
|
+
function createTraceWriter(scratchPath, meta, opts = {}) {
|
|
1478
|
+
const spillBytes = opts.spillBytes ?? DEFAULT_SPILL_BYTES;
|
|
1479
|
+
const dir = join3(scratchPath, "traces", meta.stageId, `attempt-${meta.attempt}`);
|
|
1480
|
+
mkdirSync2(join3(dir, "blobs"), { recursive: true });
|
|
1481
|
+
mkdirSync2(join3(dir, "raw"), { recursive: true });
|
|
1482
|
+
const tracePath = join3(dir, "trace.jsonl");
|
|
1483
|
+
const metaPath = join3(dir, "meta.json");
|
|
1484
|
+
let current = { ...meta };
|
|
1485
|
+
let nextSeq = 0;
|
|
1486
|
+
let rawCount = 0;
|
|
1487
|
+
writeFileSync2(metaPath, JSON.stringify(current, null, 2));
|
|
1488
|
+
const tooBig = (value) => {
|
|
1489
|
+
const s = typeof value === "string" ? value : JSON.stringify(value ?? "");
|
|
1490
|
+
return s.length > spillBytes;
|
|
1491
|
+
};
|
|
1492
|
+
const spillValue = (value) => {
|
|
1493
|
+
const data = typeof value === "string" ? value : JSON.stringify(value);
|
|
1494
|
+
const sha = createHash("sha256").update(data).digest("hex");
|
|
1495
|
+
writeFileSync2(join3(dir, "blobs", sha), data);
|
|
1496
|
+
return join3("blobs", sha);
|
|
1497
|
+
};
|
|
1498
|
+
const spill = (event) => {
|
|
1499
|
+
if (event.type === "thought" && event.text !== void 0 && tooBig(event.text)) {
|
|
1500
|
+
const { text, ...rest } = event;
|
|
1501
|
+
return { ...rest, textRef: spillValue(text) };
|
|
1502
|
+
}
|
|
1503
|
+
if (event.type === "response" && event.text !== void 0 && tooBig(event.text)) {
|
|
1504
|
+
const { text, ...rest } = event;
|
|
1505
|
+
return { ...rest, textRef: spillValue(text) };
|
|
1506
|
+
}
|
|
1507
|
+
if (event.type === "action" && event.input !== void 0 && tooBig(event.input)) {
|
|
1508
|
+
const { input, ...rest } = event;
|
|
1509
|
+
return { ...rest, inputRef: spillValue(input) };
|
|
1510
|
+
}
|
|
1511
|
+
if (event.type === "observation" && event.output !== void 0 && tooBig(event.output)) {
|
|
1512
|
+
const { output, ...rest } = event;
|
|
1513
|
+
return { ...rest, outputRef: spillValue(output) };
|
|
1514
|
+
}
|
|
1515
|
+
return event;
|
|
1516
|
+
};
|
|
1517
|
+
return {
|
|
1518
|
+
dir,
|
|
1519
|
+
append(event) {
|
|
1520
|
+
const written = { ...spill(event), seq: nextSeq++ };
|
|
1521
|
+
appendFileSync(tracePath, `${JSON.stringify(written)}
|
|
1522
|
+
`);
|
|
1523
|
+
return written;
|
|
1524
|
+
},
|
|
1525
|
+
writeRaw(record) {
|
|
1526
|
+
const rel = join3("raw", `${rawCount++}.json`);
|
|
1527
|
+
writeFileSync2(join3(dir, rel), JSON.stringify(record, null, 2));
|
|
1528
|
+
return rel;
|
|
1529
|
+
},
|
|
1530
|
+
finalise(patch = {}) {
|
|
1531
|
+
current = { ...current, ...patch };
|
|
1532
|
+
writeFileSync2(metaPath, JSON.stringify(current, null, 2));
|
|
1533
|
+
},
|
|
1534
|
+
count() {
|
|
1535
|
+
return nextSeq;
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// ../../packages/executor-worktree/src/pack-cache.ts
|
|
1541
|
+
import { createHash as createHash2 } from "crypto";
|
|
1542
|
+
import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readdirSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
1543
|
+
import { dirname as dirname2, join as join4, relative, sep } from "path";
|
|
1544
|
+
function readManifestFiles(dir) {
|
|
1545
|
+
const out = [];
|
|
1546
|
+
const walk = (cur) => {
|
|
1547
|
+
for (const entry of readdirSync(cur, { withFileTypes: true })) {
|
|
1548
|
+
const abs = join4(cur, entry.name);
|
|
1549
|
+
if (entry.isDirectory()) walk(abs);
|
|
1550
|
+
else out.push(relative(dir, abs).split(sep).join("/"));
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1553
|
+
walk(dir);
|
|
1554
|
+
return out.sort();
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// ../../packages/executor-worktree/src/overlay.ts
|
|
1558
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
1559
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
1560
|
+
function sameBytes(dest, bytes) {
|
|
1561
|
+
try {
|
|
1562
|
+
return readFileSync3(dest).equals(bytes);
|
|
1563
|
+
} catch {
|
|
1564
|
+
return false;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
async function overlayComponents(opts) {
|
|
1568
|
+
const { worktreePath, runtime, components, cache } = opts;
|
|
1569
|
+
const result = { written: [], skippedRepoLocal: [], warnings: [] };
|
|
1570
|
+
for (const ref of components) {
|
|
1571
|
+
if (runtime === "codex") {
|
|
1572
|
+
result.warnings.push(
|
|
1573
|
+
`codex runtime: ${ref.kind} \`${ref.name}@${ref.version}\` not materialised; inline into the prompt or use Claude`
|
|
1574
|
+
);
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
const { dir } = await cache.materialise(ref);
|
|
1578
|
+
for (const relPath of readManifestFiles(dir)) {
|
|
1579
|
+
const src = join5(dir, relPath);
|
|
1580
|
+
const dest = join5(worktreePath, relPath);
|
|
1581
|
+
const bytes = readFileSync3(src);
|
|
1582
|
+
if (existsSync3(dest)) {
|
|
1583
|
+
if (sameBytes(dest, bytes)) continue;
|
|
1584
|
+
result.skippedRepoLocal.push(relPath);
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
mkdirSync4(dirname3(dest), { recursive: true });
|
|
1588
|
+
writeFileSync4(dest, bytes);
|
|
1589
|
+
result.written.push(relPath);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return result;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// ../../packages/executor-worktree/src/pi-container.ts
|
|
1596
|
+
import { spawn as nodeSpawn } from "child_process";
|
|
1597
|
+
|
|
1598
|
+
// ../../packages/executor-worktree/src/pi-rpc-client.ts
|
|
1599
|
+
import { StringDecoder } from "string_decoder";
|
|
1600
|
+
|
|
1601
|
+
// ../../packages/executor-worktree/src/pi-container.ts
|
|
1602
|
+
var DEFAULT_IMAGE = process.env.DAHRK_PI_IMAGE ?? process.env.SKAKEL_PI_IMAGE ?? "dahrk/pi:latest";
|
|
1603
|
+
|
|
1604
|
+
// ../../packages/executor-worktree/src/index.ts
|
|
1605
|
+
function makeRunner(runtime) {
|
|
1606
|
+
if ((process.env.DAHRK_RUNNER ?? process.env.SKAKEL_RUNNER ?? "real") === "mock") return createMockRunner(runtime);
|
|
1607
|
+
if (runtime === "pi") return createPiRunner();
|
|
1608
|
+
return runtime === "codex" ? createCodexRunner() : createClaudeRunner();
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// ../../packages/edge/src/policy.ts
|
|
1612
|
+
var ALLOW = { verdict: "allow", policy: "none" };
|
|
1613
|
+
function evaluatePolicies(event, rules) {
|
|
1614
|
+
for (const rule of rules) {
|
|
1615
|
+
const outcome = rule.evaluate(event);
|
|
1616
|
+
if (outcome && outcome.verdict !== "allow") return outcome;
|
|
1617
|
+
}
|
|
1618
|
+
return ALLOW;
|
|
1619
|
+
}
|
|
1620
|
+
function denyToolRule(tool2) {
|
|
1621
|
+
return {
|
|
1622
|
+
name: "deny_tool",
|
|
1623
|
+
evaluate(event) {
|
|
1624
|
+
if (event.kind === "action" && event.tool === tool2) {
|
|
1625
|
+
return { verdict: "deny", policy: "deny_tool", reason: `tool "${tool2}" is denied` };
|
|
1626
|
+
}
|
|
1627
|
+
return null;
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// ../../packages/edge/src/stage-runner.ts
|
|
1633
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
1634
|
+
import { createHash as createHash3 } from "crypto";
|
|
1635
|
+
import { mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
|
|
1636
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
1637
|
+
import { join as join6 } from "path";
|
|
1638
|
+
import { attachedDocBasename as attachedDocBasename2 } from "@dahrk/contracts";
|
|
1639
|
+
|
|
1640
|
+
// ../../packages/edge/src/builtins.ts
|
|
1641
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
1642
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
1643
|
+
"Write",
|
|
1644
|
+
"Edit",
|
|
1645
|
+
"MultiEdit",
|
|
1646
|
+
"NotebookEdit",
|
|
1647
|
+
"apply_patch",
|
|
1648
|
+
"Bash",
|
|
1649
|
+
"command",
|
|
1650
|
+
"shell"
|
|
1651
|
+
]);
|
|
1652
|
+
var SHELL_TOOLS = /* @__PURE__ */ new Set(["Bash", "command", "shell"]);
|
|
1653
|
+
var DANGEROUS = [
|
|
1654
|
+
/\bsudo\b/,
|
|
1655
|
+
/\bcurl\b[^\n|]*\|\s*sh\b/,
|
|
1656
|
+
/>\s*\/dev\/(?!null|stdout|stderr|tty|fd\/)/,
|
|
1657
|
+
// write to a raw device, but allow the safe sinks (2>/dev/null etc.)
|
|
1658
|
+
/\bgit\s+push\b[^\n]*--force/,
|
|
1659
|
+
/\bchmod\s+777\b/,
|
|
1660
|
+
/\bmkfs\b/,
|
|
1661
|
+
/:\(\)\s*\{.*\};:/
|
|
1662
|
+
// fork bomb
|
|
1663
|
+
];
|
|
1664
|
+
var CATASTROPHIC_RM_TARGET = /^(\/|~|\.|\.\.|\*|\$\{?HOME\}?|\/(usr|etc|var|bin|sbin|lib|lib64|opt|boot|root|home|users|system|library|private|dev|proc|sys|applications))$/i;
|
|
1665
|
+
function isDangerousRm(cmd) {
|
|
1666
|
+
for (const seg of cmd.split(/[\n;&|]+/)) {
|
|
1667
|
+
const m = seg.match(/\brm\b(.*)/s);
|
|
1668
|
+
if (!m) continue;
|
|
1669
|
+
const tokens = (m[1] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
1670
|
+
const recursive = tokens.some((t) => t === "--recursive" || /^-[a-z]*r/i.test(t));
|
|
1671
|
+
if (!recursive) continue;
|
|
1672
|
+
const targets = tokens.filter((t) => !t.startsWith("-"));
|
|
1673
|
+
if (targets.length === 0) return true;
|
|
1674
|
+
for (const raw of targets) {
|
|
1675
|
+
const t = raw.replace(/^['"]|['"]$/g, "").replace(/\/+\*?$/, "");
|
|
1676
|
+
if (t === "" || CATASTROPHIC_RM_TARGET.test(t)) return true;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
return false;
|
|
1680
|
+
}
|
|
1681
|
+
function currentBranch(worktreePath) {
|
|
1682
|
+
try {
|
|
1683
|
+
return execFileSync2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: worktreePath }).toString().trim();
|
|
1684
|
+
} catch {
|
|
1685
|
+
return "";
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
function globMatch(globs, value) {
|
|
1689
|
+
return globs.some((g) => {
|
|
1690
|
+
const re = new RegExp(`^${g.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`);
|
|
1691
|
+
return re.test(value);
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
function commandOf(input) {
|
|
1695
|
+
if (typeof input === "string") return input;
|
|
1696
|
+
if (input && typeof input === "object") {
|
|
1697
|
+
const o = input;
|
|
1698
|
+
if (typeof o.command === "string") return o.command;
|
|
1699
|
+
}
|
|
1700
|
+
return "";
|
|
1701
|
+
}
|
|
1702
|
+
function buildRules(policies, ctx) {
|
|
1703
|
+
const rules = [];
|
|
1704
|
+
let stageToolCalls = 0;
|
|
1705
|
+
for (const p of policies) {
|
|
1706
|
+
if ("write_scope" in p) {
|
|
1707
|
+
const { branches, repos } = p.write_scope;
|
|
1708
|
+
rules.push({
|
|
1709
|
+
name: "write_scope",
|
|
1710
|
+
evaluate(event) {
|
|
1711
|
+
if (event.kind !== "action" || !WRITE_TOOLS.has(event.tool)) return null;
|
|
1712
|
+
if (repos && repos.length && !repos.includes(ctx.repoName)) {
|
|
1713
|
+
return { verdict: "deny", policy: "write_scope", reason: `repo "${ctx.repoName}" is out of write scope` };
|
|
1714
|
+
}
|
|
1715
|
+
if (branches && branches.length) {
|
|
1716
|
+
const b = currentBranch(ctx.worktreePath);
|
|
1717
|
+
if (!globMatch(branches, b)) {
|
|
1718
|
+
return {
|
|
1719
|
+
verdict: "deny",
|
|
1720
|
+
policy: "write_scope",
|
|
1721
|
+
reason: `branch "${b}" is out of write scope (${branches.join(", ")})`
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
return null;
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
} else if ("max_tool_calls" in p) {
|
|
1729
|
+
const { perStage, perRun } = p.max_tool_calls;
|
|
1730
|
+
rules.push({
|
|
1731
|
+
name: "max_tool_calls",
|
|
1732
|
+
evaluate(event) {
|
|
1733
|
+
if (event.kind !== "action") return null;
|
|
1734
|
+
stageToolCalls++;
|
|
1735
|
+
ctx.runToolCalls.count++;
|
|
1736
|
+
if (perStage !== void 0 && stageToolCalls > perStage) {
|
|
1737
|
+
return { verdict: "deny", policy: "max_tool_calls", reason: `exceeded ${perStage} tool calls this stage` };
|
|
1738
|
+
}
|
|
1739
|
+
if (perRun !== void 0 && ctx.runToolCalls.count > perRun) {
|
|
1740
|
+
return { verdict: "deny", policy: "max_tool_calls", reason: `exceeded ${perRun} tool calls this run` };
|
|
1741
|
+
}
|
|
1742
|
+
return null;
|
|
1743
|
+
}
|
|
1744
|
+
});
|
|
1745
|
+
} else if ("shell_guard" in p) {
|
|
1746
|
+
rules.push({
|
|
1747
|
+
name: "shell_guard",
|
|
1748
|
+
evaluate(event) {
|
|
1749
|
+
if (event.kind !== "action" || !SHELL_TOOLS.has(event.tool)) return null;
|
|
1750
|
+
const cmd = commandOf(event.input);
|
|
1751
|
+
if (isDangerousRm(cmd) || DANGEROUS.some((re) => re.test(cmd))) {
|
|
1752
|
+
return { verdict: "deny", policy: "shell_guard", reason: `shell command blocked: ${cmd.slice(0, 80)}` };
|
|
1753
|
+
}
|
|
1754
|
+
return null;
|
|
1755
|
+
}
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
return rules;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// ../../packages/edge/src/mcp-gateway.ts
|
|
1763
|
+
import http from "http";
|
|
1764
|
+
import { Readable } from "stream";
|
|
1765
|
+
import { pipeline } from "stream/promises";
|
|
1766
|
+
var STRIP_REQUEST_HEADERS = /* @__PURE__ */ new Set(["host", "connection", "content-length", "authorization"]);
|
|
1767
|
+
async function startMcpGateway(opts) {
|
|
1768
|
+
const byId = new Map(opts.servers.map((s) => [s.id, s]));
|
|
1769
|
+
const server = http.createServer((req, res) => {
|
|
1770
|
+
void handle(req, res).catch(() => {
|
|
1771
|
+
if (!res.headersSent) res.writeHead(502);
|
|
1772
|
+
res.end();
|
|
1773
|
+
});
|
|
1774
|
+
});
|
|
1775
|
+
const handle = async (req, res) => {
|
|
1776
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1777
|
+
const segs = url.pathname.split("/").filter(Boolean);
|
|
1778
|
+
const id = segs[0];
|
|
1779
|
+
const target = id ? byId.get(id) : void 0;
|
|
1780
|
+
if (!target) {
|
|
1781
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
1782
|
+
res.end(JSON.stringify({ error: "unknown mcp server" }));
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
const restPath = segs.slice(1).join("/");
|
|
1786
|
+
const base = target.url.replace(/\/+$/, "");
|
|
1787
|
+
const finalUrl = restPath ? `${base}/${restPath}${url.search}` : `${base}${url.search}`;
|
|
1788
|
+
const headers = new Headers();
|
|
1789
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
1790
|
+
if (v === void 0 || STRIP_REQUEST_HEADERS.has(k.toLowerCase())) continue;
|
|
1791
|
+
headers.set(k, Array.isArray(v) ? v.join(", ") : v);
|
|
1792
|
+
}
|
|
1793
|
+
const token = opts.creds[id];
|
|
1794
|
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
1795
|
+
const method = req.method ?? "GET";
|
|
1796
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
1797
|
+
const upstream = await fetch(finalUrl, {
|
|
1798
|
+
method,
|
|
1799
|
+
headers,
|
|
1800
|
+
...hasBody ? { body: Readable.toWeb(req), duplex: "half" } : {}
|
|
1801
|
+
});
|
|
1802
|
+
const outHeaders = {};
|
|
1803
|
+
upstream.headers.forEach((value, key) => {
|
|
1804
|
+
if (key.toLowerCase() === "content-length") return;
|
|
1805
|
+
outHeaders[key] = value;
|
|
1806
|
+
});
|
|
1807
|
+
res.writeHead(upstream.status, outHeaders);
|
|
1808
|
+
if (upstream.body) await pipeline(Readable.fromWeb(upstream.body), res);
|
|
1809
|
+
else res.end();
|
|
1810
|
+
};
|
|
1811
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
1812
|
+
const { port } = server.address();
|
|
1813
|
+
return {
|
|
1814
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
1815
|
+
stop: () => new Promise((resolve) => server.close(() => resolve()))
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// ../../packages/edge/src/stage-runner.ts
|
|
1820
|
+
var nowIso2 = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
1821
|
+
var PREVIEW = 500;
|
|
1822
|
+
var putBytes = async (url, body, contentType) => {
|
|
1823
|
+
await fetch(url, { method: "PUT", headers: { "content-type": contentType }, body: new Uint8Array(body) });
|
|
1824
|
+
};
|
|
1825
|
+
function previewOf(event) {
|
|
1826
|
+
const clip = (v) => (typeof v === "string" ? v : JSON.stringify(v) ?? "").slice(0, PREVIEW);
|
|
1827
|
+
switch (event.type) {
|
|
1828
|
+
case "response":
|
|
1829
|
+
return event.text !== void 0 ? { text: event.text } : {};
|
|
1830
|
+
case "elicitation":
|
|
1831
|
+
return { text: event.prompt };
|
|
1832
|
+
case "thought":
|
|
1833
|
+
return event.text !== void 0 ? { text: clip(event.text) } : {};
|
|
1834
|
+
case "action":
|
|
1835
|
+
return { tool: event.tool, ...event.input !== void 0 ? { text: clip(event.input) } : {} };
|
|
1836
|
+
case "observation":
|
|
1837
|
+
return event.output !== void 0 ? { text: clip(event.output) } : {};
|
|
1838
|
+
case "error":
|
|
1839
|
+
return { text: clip(event.message) };
|
|
1840
|
+
default:
|
|
1841
|
+
return {};
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
var attemptOf = (jobId) => {
|
|
1845
|
+
const m = /-(\d+)$/.exec(jobId);
|
|
1846
|
+
return m ? Number(m[1]) : 1;
|
|
1847
|
+
};
|
|
1848
|
+
var digest = (value) => `sha256:${createHash3("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16)}`;
|
|
1849
|
+
function writeScratchState(ref, job, attempt, status) {
|
|
1850
|
+
const statePath = join6(ref.scratchPath, "state.json");
|
|
1851
|
+
let state;
|
|
1852
|
+
try {
|
|
1853
|
+
state = JSON.parse(readFileSync4(statePath, "utf8"));
|
|
1854
|
+
} catch {
|
|
1855
|
+
state = { runId: job.runId, tenantId: job.tenantId, stages: {} };
|
|
1856
|
+
}
|
|
1857
|
+
state.stages[job.stageId] = { currentAttempt: attempt, status };
|
|
1858
|
+
mkdirSync5(ref.scratchPath, { recursive: true });
|
|
1859
|
+
writeFileSync5(statePath, JSON.stringify(state, null, 2));
|
|
1860
|
+
}
|
|
1861
|
+
function writeIssueContext(ref, issueContext) {
|
|
1862
|
+
if (issueContext === void 0) return;
|
|
1863
|
+
try {
|
|
1864
|
+
mkdirSync5(ref.scratchPath, { recursive: true });
|
|
1865
|
+
writeFileSync5(join6(ref.scratchPath, "issue.md"), issueContext);
|
|
1866
|
+
} catch {
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
function writeAttachedDocuments(ref, docs) {
|
|
1870
|
+
if (!docs || docs.length === 0) return;
|
|
1871
|
+
try {
|
|
1872
|
+
const dir = join6(ref.scratchPath, "docs");
|
|
1873
|
+
mkdirSync5(dir, { recursive: true });
|
|
1874
|
+
for (const doc of docs) {
|
|
1875
|
+
writeFileSync5(join6(dir, `${attachedDocBasename2(doc)}.md`), doc.content);
|
|
1876
|
+
}
|
|
1877
|
+
} catch {
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
function renderGuidanceMarkdown(guidance) {
|
|
1881
|
+
const lines = guidance.map((rule) => {
|
|
1882
|
+
const scope = rule.teamName ? `${rule.origin}: ${rule.teamName}` : rule.origin;
|
|
1883
|
+
return `- (${scope}) ${rule.content.trim()}`;
|
|
1884
|
+
});
|
|
1885
|
+
return `# Workspace guidance
|
|
1886
|
+
|
|
1887
|
+
${lines.join("\n")}
|
|
1888
|
+
`;
|
|
1889
|
+
}
|
|
1890
|
+
function writeGuidance(ref, guidance) {
|
|
1891
|
+
if (!guidance || guidance.length === 0) return;
|
|
1892
|
+
try {
|
|
1893
|
+
mkdirSync5(ref.scratchPath, { recursive: true });
|
|
1894
|
+
writeFileSync5(join6(ref.scratchPath, "guidance.md"), renderGuidanceMarkdown(guidance));
|
|
1895
|
+
} catch {
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
var ARTIFACT_CAP_BYTES = 64 * 1024;
|
|
1899
|
+
var SCRATCH_OUTPUT_DIR = ".skakel/scratch/output";
|
|
1900
|
+
function capContent(raw) {
|
|
1901
|
+
return raw.length > ARTIFACT_CAP_BYTES ? raw.slice(0, ARTIFACT_CAP_BYTES) : raw;
|
|
1902
|
+
}
|
|
1903
|
+
function readEmittedArtifact(ref, relPath) {
|
|
1904
|
+
try {
|
|
1905
|
+
const raw = readFileSync4(join6(ref.worktreePath, relPath), "utf8");
|
|
1906
|
+
return { path: relPath, content: capContent(raw) };
|
|
1907
|
+
} catch {
|
|
1908
|
+
return void 0;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
function scanScratchOutput(ref, preferRel) {
|
|
1912
|
+
try {
|
|
1913
|
+
const names = readdirSync2(join6(ref.worktreePath, SCRATCH_OUTPUT_DIR)).filter(
|
|
1914
|
+
(n) => n.toLowerCase().endsWith(".md")
|
|
1915
|
+
);
|
|
1916
|
+
if (names.length === 0) return void 0;
|
|
1917
|
+
const preferBase = preferRel?.split("/").pop();
|
|
1918
|
+
const pick = preferBase && names.includes(preferBase) ? preferBase : names[0];
|
|
1919
|
+
const raw = readFileSync4(join6(ref.worktreePath, SCRATCH_OUTPUT_DIR, pick), "utf8");
|
|
1920
|
+
if (raw.trim().length === 0) return void 0;
|
|
1921
|
+
return { path: `${SCRATCH_OUTPUT_DIR}/${pick}`, content: capContent(raw) };
|
|
1922
|
+
} catch {
|
|
1923
|
+
return void 0;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
function scanChangedMarkdown(ref) {
|
|
1927
|
+
const git = (args) => {
|
|
1928
|
+
try {
|
|
1929
|
+
return execFileSync3("git", ["-C", ref.worktreePath, ...args], { encoding: "utf8" }).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
1930
|
+
} catch {
|
|
1931
|
+
return [];
|
|
1932
|
+
}
|
|
1933
|
+
};
|
|
1934
|
+
const rels = [...git(["ls-files", "--others", "--exclude-standard"]), ...git(["diff", "--name-only"])].filter(
|
|
1935
|
+
(p) => p.toLowerCase().endsWith(".md")
|
|
1936
|
+
);
|
|
1937
|
+
for (const rel of rels) {
|
|
1938
|
+
try {
|
|
1939
|
+
const raw = readFileSync4(join6(ref.worktreePath, rel), "utf8");
|
|
1940
|
+
if (raw.trim().length > 0) return { path: rel, content: capContent(raw) };
|
|
1941
|
+
} catch {
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
return void 0;
|
|
1945
|
+
}
|
|
1946
|
+
function resolveStageArtifact(ref, emitArtifact, handedBack) {
|
|
1947
|
+
if (emitArtifact) {
|
|
1948
|
+
const declared = readEmittedArtifact(ref, emitArtifact);
|
|
1949
|
+
if (declared && declared.content.trim().length > 0) return { artifact: declared, source: "declared-file" };
|
|
1950
|
+
}
|
|
1951
|
+
if (handedBack && handedBack.content.trim().length > 0) {
|
|
1952
|
+
return { artifact: { path: handedBack.path, content: capContent(handedBack.content) }, source: "tool-handoff" };
|
|
1953
|
+
}
|
|
1954
|
+
const scratch = scanScratchOutput(ref, emitArtifact);
|
|
1955
|
+
if (scratch) return { artifact: scratch, source: "scratch-scan" };
|
|
1956
|
+
const changed = scanChangedMarkdown(ref);
|
|
1957
|
+
if (changed) return { artifact: changed, source: "changed-file" };
|
|
1958
|
+
return void 0;
|
|
1959
|
+
}
|
|
1960
|
+
function createStageRunner(deps) {
|
|
1961
|
+
const worktrees = /* @__PURE__ */ new Map();
|
|
1962
|
+
const active = /* @__PURE__ */ new Map();
|
|
1963
|
+
const turnQueues = /* @__PURE__ */ new Map();
|
|
1964
|
+
const runToolCalls = /* @__PURE__ */ new Map();
|
|
1965
|
+
const lastUsed = /* @__PURE__ */ new Map();
|
|
1966
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
1967
|
+
const scratchOnly = /* @__PURE__ */ new Set();
|
|
1968
|
+
const teardownRun = async (runId) => {
|
|
1969
|
+
const ref = worktrees.get(runId);
|
|
1970
|
+
if (ref) {
|
|
1971
|
+
if (scratchOnly.has(runId)) {
|
|
1972
|
+
try {
|
|
1973
|
+
rmSync3(ref.worktreePath, { recursive: true, force: true });
|
|
1974
|
+
} catch {
|
|
1975
|
+
}
|
|
1976
|
+
} else {
|
|
1977
|
+
await deps.gitService.teardownWorktree(ref).catch(() => void 0);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
worktrees.delete(runId);
|
|
1981
|
+
scratchOnly.delete(runId);
|
|
1982
|
+
lastUsed.delete(runId);
|
|
1983
|
+
runToolCalls.delete(runId);
|
|
1984
|
+
};
|
|
1985
|
+
const applyRetention = async (keepRunId) => {
|
|
1986
|
+
const policy = deps.retention;
|
|
1987
|
+
if (!policy) return;
|
|
1988
|
+
const prunable = (id) => id !== keepRunId && (inFlight.get(id) ?? 0) === 0;
|
|
1989
|
+
const now = Date.now();
|
|
1990
|
+
if (policy.maxAgeMs !== void 0) {
|
|
1991
|
+
for (const id of [...worktrees.keys()]) {
|
|
1992
|
+
if (prunable(id) && now - (lastUsed.get(id) ?? 0) > policy.maxAgeMs) await teardownRun(id);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
if (policy.maxRuns !== void 0 && worktrees.size > policy.maxRuns) {
|
|
1996
|
+
const byOldest = [...worktrees.keys()].filter(prunable).sort((a, b) => (lastUsed.get(a) ?? 0) - (lastUsed.get(b) ?? 0));
|
|
1997
|
+
let excess = worktrees.size - policy.maxRuns;
|
|
1998
|
+
for (const id of byOldest) {
|
|
1999
|
+
if (excess <= 0) break;
|
|
2000
|
+
await teardownRun(id);
|
|
2001
|
+
excess--;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
return {
|
|
2006
|
+
async runJob(job) {
|
|
2007
|
+
const { stageId, jobId, runId, agentConfig } = job;
|
|
2008
|
+
const attempt = attemptOf(jobId);
|
|
2009
|
+
if (deps.tenantId && job.tenantId !== deps.tenantId) {
|
|
2010
|
+
return {
|
|
2011
|
+
jobId,
|
|
2012
|
+
status: "fail",
|
|
2013
|
+
summary: `node (tenant "${deps.tenantId}") refuses a job for tenant "${job.tenantId}"`
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
const allow = deps.servesRepoIds;
|
|
2017
|
+
if (job.workspaceRef && allow && allow.length > 0 && !allow.includes(job.workspaceRef.repoId)) {
|
|
2018
|
+
return { jobId, status: "fail", summary: `edge does not serve repo "${job.workspaceRef.repoId}"` };
|
|
2019
|
+
}
|
|
2020
|
+
inFlight.set(runId, (inFlight.get(runId) ?? 0) + 1);
|
|
2021
|
+
lastUsed.set(runId, Date.now());
|
|
2022
|
+
let ref = worktrees.get(runId);
|
|
2023
|
+
if (!ref) {
|
|
2024
|
+
if (job.workspaceRef) {
|
|
2025
|
+
ref = await deps.gitService.createWorktree({
|
|
2026
|
+
repoId: job.workspaceRef.repoId,
|
|
2027
|
+
gitUrl: job.workspaceRef.gitUrl,
|
|
2028
|
+
baseBranch: job.workspaceRef.baseBranch,
|
|
2029
|
+
runId,
|
|
2030
|
+
repo: job.workspaceRef.repo,
|
|
2031
|
+
// Stable per-issue branch (continuation); absent = legacy dahrk/<runId>.
|
|
2032
|
+
...job.workspaceRef.branch ? { branch: job.workspaceRef.branch } : {},
|
|
2033
|
+
// Brokered git credential for this job; absent on ambient nodes (host creds used).
|
|
2034
|
+
...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
|
|
2035
|
+
});
|
|
2036
|
+
} else {
|
|
2037
|
+
const base = deps.scratchRoot ?? join6(tmpdir2(), "dahrk", "scratch");
|
|
2038
|
+
const worktreePath = join6(base, runId);
|
|
2039
|
+
const scratchPath = join6(worktreePath, ".skakel", "scratch");
|
|
2040
|
+
mkdirSync5(scratchPath, { recursive: true });
|
|
2041
|
+
ref = { repoId: "", gitUrl: "", repo: "", baseBranch: "", worktreePath, scratchPath };
|
|
2042
|
+
scratchOnly.add(runId);
|
|
2043
|
+
}
|
|
2044
|
+
worktrees.set(runId, ref);
|
|
2045
|
+
}
|
|
2046
|
+
writeScratchState(ref, job, attempt, "in-flight");
|
|
2047
|
+
writeIssueContext(ref, job.issueContext);
|
|
2048
|
+
writeGuidance(ref, job.guidance);
|
|
2049
|
+
writeAttachedDocuments(ref, job.attachedDocuments);
|
|
2050
|
+
if (job.agentConfig.emitArtifact) {
|
|
2051
|
+
const slash = job.agentConfig.emitArtifact.lastIndexOf("/");
|
|
2052
|
+
if (slash > 0) {
|
|
2053
|
+
try {
|
|
2054
|
+
mkdirSync5(join6(ref.worktreePath, job.agentConfig.emitArtifact.slice(0, slash)), { recursive: true });
|
|
2055
|
+
} catch {
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
let gateway;
|
|
2060
|
+
const meta = {
|
|
2061
|
+
tenantId: job.tenantId,
|
|
2062
|
+
runId,
|
|
2063
|
+
stageId,
|
|
2064
|
+
jobId,
|
|
2065
|
+
attempt,
|
|
2066
|
+
runtime: agentConfig.runtime,
|
|
2067
|
+
model: agentConfig.model,
|
|
2068
|
+
sessionId: job.sessionId,
|
|
2069
|
+
configDigest: digest(agentConfig),
|
|
2070
|
+
startedAt: nowIso2()
|
|
2071
|
+
};
|
|
2072
|
+
const writer = createTraceWriter(ref.scratchPath, meta);
|
|
2073
|
+
const streamEvent = (e) => deps.trace?.event({ runId, stageId, attempt, tenantId: job.tenantId, event: e });
|
|
2074
|
+
streamEvent(
|
|
2075
|
+
writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "attempt-start" })
|
|
2076
|
+
);
|
|
2077
|
+
const shipFinalTrace = async (finalMeta) => {
|
|
2078
|
+
const sink = deps.trace;
|
|
2079
|
+
if (!sink) return;
|
|
2080
|
+
const base = { tenantId: job.tenantId, runId, stageId, attempt };
|
|
2081
|
+
try {
|
|
2082
|
+
for (const name of readdirSync2(join6(writer.dir, "blobs"))) {
|
|
2083
|
+
const bytes = readFileSync4(join6(writer.dir, "blobs", name));
|
|
2084
|
+
const { url } = await sink.requestBlobUrl({
|
|
2085
|
+
...base,
|
|
2086
|
+
sha256: name,
|
|
2087
|
+
size: bytes.length,
|
|
2088
|
+
contentType: "application/octet-stream",
|
|
2089
|
+
slot: "blob"
|
|
2090
|
+
});
|
|
2091
|
+
if (url) await putBytes(url, bytes, "application/octet-stream");
|
|
2092
|
+
}
|
|
2093
|
+
} catch {
|
|
2094
|
+
}
|
|
2095
|
+
let archiveKey;
|
|
2096
|
+
try {
|
|
2097
|
+
const bytes = readFileSync4(join6(writer.dir, "trace.jsonl"));
|
|
2098
|
+
const sha = createHash3("sha256").update(bytes).digest("hex");
|
|
2099
|
+
const { key, url } = await sink.requestBlobUrl({
|
|
2100
|
+
...base,
|
|
2101
|
+
sha256: sha,
|
|
2102
|
+
size: bytes.length,
|
|
2103
|
+
contentType: "application/x-ndjson",
|
|
2104
|
+
slot: "archive"
|
|
2105
|
+
});
|
|
2106
|
+
if (url) await putBytes(url, bytes, "application/x-ndjson");
|
|
2107
|
+
archiveKey = key;
|
|
2108
|
+
} catch {
|
|
2109
|
+
}
|
|
2110
|
+
sink.finalised({ ...base, meta: finalMeta, eventCount: writer.count(), ...archiveKey ? { archiveKey } : {} });
|
|
2111
|
+
};
|
|
2112
|
+
const finish = async (status2, summary2, sessionId, costUsd, handedBackDoc) => {
|
|
2113
|
+
active.delete(jobId);
|
|
2114
|
+
turnQueues.delete(jobId);
|
|
2115
|
+
await gateway?.stop().catch(() => void 0);
|
|
2116
|
+
gateway = void 0;
|
|
2117
|
+
streamEvent(
|
|
2118
|
+
writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "stage-exit", status: status2 })
|
|
2119
|
+
);
|
|
2120
|
+
const endedAt = nowIso2();
|
|
2121
|
+
writer.finalise({ status: status2, endedAt, ...sessionId ? { sessionId } : {} });
|
|
2122
|
+
writeScratchState(ref, job, attempt, status2);
|
|
2123
|
+
const finalMeta = {
|
|
2124
|
+
...meta,
|
|
2125
|
+
status: status2,
|
|
2126
|
+
endedAt,
|
|
2127
|
+
...sessionId ? { sessionId } : {},
|
|
2128
|
+
...costUsd !== void 0 ? { costUsd } : {}
|
|
2129
|
+
};
|
|
2130
|
+
await shipFinalTrace(finalMeta).catch(() => void 0);
|
|
2131
|
+
lastUsed.set(runId, Date.now());
|
|
2132
|
+
inFlight.set(runId, Math.max(0, (inFlight.get(runId) ?? 1) - 1));
|
|
2133
|
+
await applyRetention(runId).catch(() => void 0);
|
|
2134
|
+
const wantsArtifact = agentConfig.emitArtifact !== void 0 || handedBackDoc !== void 0;
|
|
2135
|
+
const resolved = status2 === "ok" && wantsArtifact ? resolveStageArtifact(ref, agentConfig.emitArtifact, handedBackDoc) : void 0;
|
|
2136
|
+
if (status2 === "ok" && wantsArtifact) {
|
|
2137
|
+
const detail = resolved ? `source=${resolved.source} path=${resolved.artifact.path} bytes=${resolved.artifact.content.length}` : "no document resolved (declared path, tool handoff, and scratch/changed-file scans all empty)";
|
|
2138
|
+
streamEvent(
|
|
2139
|
+
writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "artifact", detail })
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
return {
|
|
2143
|
+
jobId,
|
|
2144
|
+
status: status2,
|
|
2145
|
+
summary: summary2,
|
|
2146
|
+
...sessionId ? { sessionId } : {},
|
|
2147
|
+
...costUsd !== void 0 ? { costUsd } : {},
|
|
2148
|
+
...resolved ? { artifact: resolved.artifact } : {}
|
|
2149
|
+
};
|
|
2150
|
+
};
|
|
2151
|
+
if (deps.packCache && job.provision && job.provision.length > 0) {
|
|
2152
|
+
try {
|
|
2153
|
+
const overlay = await overlayComponents({
|
|
2154
|
+
worktreePath: ref.worktreePath,
|
|
2155
|
+
runtime: agentConfig.runtime,
|
|
2156
|
+
components: job.provision,
|
|
2157
|
+
cache: deps.packCache
|
|
2158
|
+
});
|
|
2159
|
+
const detail = `provision: ${overlay.written.length} written, ${overlay.skippedRepoLocal.length} repo-local, ${overlay.warnings.length} warning(s)`;
|
|
2160
|
+
streamEvent(
|
|
2161
|
+
writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "provision", detail })
|
|
2162
|
+
);
|
|
2163
|
+
const noteText = overlay.warnings.length > 0 ? `${detail}; ${overlay.warnings.join("; ")}` : detail;
|
|
2164
|
+
deps.sendProgress({ jobId, kind: "observation", ts: nowIso2(), text: noteText });
|
|
2165
|
+
} catch (e) {
|
|
2166
|
+
const msg = `component provisioning failed: ${e.message}`;
|
|
2167
|
+
writer.append({ seq: 0, ts: nowIso2(), type: "error", runtime: agentConfig.runtime, kind: "provision-failed", message: msg });
|
|
2168
|
+
deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: msg });
|
|
2169
|
+
return finish("fail", `${stageId}: ${msg}`, job.sessionId);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
let counter = runToolCalls.get(runId);
|
|
2173
|
+
if (!counter) {
|
|
2174
|
+
counter = { count: 0 };
|
|
2175
|
+
runToolCalls.set(runId, counter);
|
|
2176
|
+
}
|
|
2177
|
+
const jobRules = buildRules(job.policies ?? [], {
|
|
2178
|
+
worktreePath: ref.worktreePath,
|
|
2179
|
+
repoName: job.workspaceRef?.repo ?? "",
|
|
2180
|
+
runToolCalls: counter
|
|
2181
|
+
});
|
|
2182
|
+
const rules = [...jobRules, ...deps.rules];
|
|
2183
|
+
const entry = evaluatePolicies({ kind: "stage-entry", stageId }, rules);
|
|
2184
|
+
if (entry.verdict === "deny") {
|
|
2185
|
+
writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "policy-deny", detail: entry.reason });
|
|
2186
|
+
return finish("fail", `${stageId}: denied at stage entry (${entry.policy})`, job.sessionId);
|
|
2187
|
+
}
|
|
2188
|
+
let denied = false;
|
|
2189
|
+
const runtime = agentConfig.runtime;
|
|
2190
|
+
const onTrace = (event) => {
|
|
2191
|
+
if (event.type === "action") {
|
|
2192
|
+
const verdict = evaluatePolicies(
|
|
2193
|
+
{ kind: "action", stageId, tool: event.tool, input: event.input },
|
|
2194
|
+
rules
|
|
2195
|
+
);
|
|
2196
|
+
if (verdict.verdict === "deny") {
|
|
2197
|
+
denied = true;
|
|
2198
|
+
streamEvent(writer.append(event));
|
|
2199
|
+
streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "observation", runtime, toolUseId: event.toolUseId, isError: true, output: { error: verdict.reason } }));
|
|
2200
|
+
streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime, event: "policy-deny", detail: verdict.reason }));
|
|
2201
|
+
deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: verdict.reason });
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
streamEvent(writer.append(event));
|
|
2206
|
+
if (event.type !== "state") deps.sendProgress({ jobId, kind: event.type, ts: event.ts, ...previewOf(event) });
|
|
2207
|
+
};
|
|
2208
|
+
const mcpServers = agentConfig.mcpServers;
|
|
2209
|
+
if (mcpServers && mcpServers.length > 0 && runtime === "claude-code") {
|
|
2210
|
+
gateway = await startMcpGateway({ servers: mcpServers, creds: job.brokeredCreds ?? {} });
|
|
2211
|
+
}
|
|
2212
|
+
const runner = deps.makeRunner(runtime);
|
|
2213
|
+
active.set(jobId, runner);
|
|
2214
|
+
const ctx = {
|
|
2215
|
+
config: agentConfig,
|
|
2216
|
+
workspace: ref,
|
|
2217
|
+
sessionId: job.sessionId,
|
|
2218
|
+
...job.issueContext !== void 0 ? { issueContext: job.issueContext } : {},
|
|
2219
|
+
...job.guidance !== void 0 ? { guidance: job.guidance } : {},
|
|
2220
|
+
...job.gateFeedback !== void 0 ? { gateFeedback: job.gateFeedback } : {},
|
|
2221
|
+
...job.attachedDocuments !== void 0 ? { attachedDocuments: job.attachedDocuments } : {},
|
|
2222
|
+
...gateway ? { mcpProxyBaseUrl: gateway.baseUrl } : {},
|
|
2223
|
+
// brokered inference env for a managed node (no operator login). The runtime adapter
|
|
2224
|
+
// (Pi) / container executor apply it as the inference process env, so the raw key is
|
|
2225
|
+
// never surfaced to the agent's own tool calls. Absent on ambient nodes; inert for the Claude/
|
|
2226
|
+
// Codex adapters, which use ambient inference.
|
|
2227
|
+
...job.runtimeEnv ? { runtimeEnv: job.runtimeEnv } : {},
|
|
2228
|
+
// The adapter persists each runtime-native record under the attempt's raw/ sidecar
|
|
2229
|
+
// and stamps the rawRef onto the emitted event.
|
|
2230
|
+
writeRaw: writer.writeRaw
|
|
2231
|
+
};
|
|
2232
|
+
const interactive = agentConfig.interaction === "interactive";
|
|
2233
|
+
let result;
|
|
2234
|
+
let timedOut = false;
|
|
2235
|
+
const killMs = Math.max(0, Math.floor((job.timeout ?? 0) * 1e3));
|
|
2236
|
+
const killTimer = killMs > 0 ? setTimeout(() => {
|
|
2237
|
+
timedOut = true;
|
|
2238
|
+
void runner.cancel();
|
|
2239
|
+
}, killMs) : void 0;
|
|
2240
|
+
try {
|
|
2241
|
+
if (interactive) {
|
|
2242
|
+
const mailbox = new ManagedMailbox();
|
|
2243
|
+
turnQueues.set(jobId, mailbox);
|
|
2244
|
+
result = await runner.runInteractive(ctx, mailbox, onTrace);
|
|
2245
|
+
} else {
|
|
2246
|
+
result = await runner.runBatch(ctx, onTrace);
|
|
2247
|
+
}
|
|
2248
|
+
} finally {
|
|
2249
|
+
if (killTimer) clearTimeout(killTimer);
|
|
2250
|
+
}
|
|
2251
|
+
let status = timedOut ? "timeout" : result.status;
|
|
2252
|
+
if (status === "ok" && job.hooks && job.hooks.length > 0) {
|
|
2253
|
+
for (const cmd of job.hooks) {
|
|
2254
|
+
try {
|
|
2255
|
+
execFileSync3("sh", ["-c", cmd], { cwd: ref.worktreePath, stdio: ["pipe", "pipe", "pipe"] });
|
|
2256
|
+
} catch (e) {
|
|
2257
|
+
status = "fail";
|
|
2258
|
+
writer.append({ seq: 0, ts: nowIso2(), type: "error", runtime, kind: "hook-failed", message: `hook "${cmd}" failed: ${e.message}` });
|
|
2259
|
+
break;
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
let summary = result.summary ?? `${stageId}: ${status}`;
|
|
2264
|
+
if (!interactive && status === "ok") {
|
|
2265
|
+
try {
|
|
2266
|
+
summary = await runner.summarise(ctx);
|
|
2267
|
+
} catch {
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
if (denied) summary += "\n\n(note: one or more tool actions were blocked by a deny-only policy guard.)";
|
|
2271
|
+
return finish(status, summary, result.sessionId ?? job.sessionId, result.costUsd, result.artifact);
|
|
2272
|
+
},
|
|
2273
|
+
async runPush(job) {
|
|
2274
|
+
const { runId, jobId } = job;
|
|
2275
|
+
const allow = deps.servesRepoIds;
|
|
2276
|
+
if (allow && allow.length > 0 && !allow.includes(job.workspaceRef.repoId)) {
|
|
2277
|
+
return { jobId, status: "fail", summary: `edge does not serve repo "${job.workspaceRef.repoId}"` };
|
|
2278
|
+
}
|
|
2279
|
+
lastUsed.set(runId, Date.now());
|
|
2280
|
+
let ref = worktrees.get(runId);
|
|
2281
|
+
if (!ref) {
|
|
2282
|
+
ref = await deps.gitService.createWorktree({
|
|
2283
|
+
repoId: job.workspaceRef.repoId,
|
|
2284
|
+
gitUrl: job.workspaceRef.gitUrl,
|
|
2285
|
+
baseBranch: job.workspaceRef.baseBranch,
|
|
2286
|
+
runId,
|
|
2287
|
+
repo: job.workspaceRef.repo,
|
|
2288
|
+
branch: job.branch,
|
|
2289
|
+
...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
|
|
2290
|
+
});
|
|
2291
|
+
worktrees.set(runId, ref);
|
|
2292
|
+
}
|
|
2293
|
+
try {
|
|
2294
|
+
const r = await deps.gitService.commitAndPush(ref, {
|
|
2295
|
+
message: job.message,
|
|
2296
|
+
branch: job.branch,
|
|
2297
|
+
base: job.base,
|
|
2298
|
+
...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
|
|
2299
|
+
});
|
|
2300
|
+
if (r.integration === "conflict") {
|
|
2301
|
+
return {
|
|
2302
|
+
jobId,
|
|
2303
|
+
status: "ok",
|
|
2304
|
+
branch: job.branch,
|
|
2305
|
+
headSha: r.headSha,
|
|
2306
|
+
pushed: false,
|
|
2307
|
+
nothingToCommit: r.nothingToCommit,
|
|
2308
|
+
commitsAhead: r.commitsAhead,
|
|
2309
|
+
integration: "conflict",
|
|
2310
|
+
...r.conflictFiles ? { conflictFiles: r.conflictFiles } : {},
|
|
2311
|
+
summary: `base advanced; merge conflict on ${job.branch} (manual merge needed)`
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
const pr = job.openPr && r.pushed ? await deps.gitService.openPrAmbient(ref, {
|
|
2315
|
+
branch: job.branch,
|
|
2316
|
+
base: job.base,
|
|
2317
|
+
title: job.openPr.title,
|
|
2318
|
+
body: job.openPr.body
|
|
2319
|
+
}) : void 0;
|
|
2320
|
+
return {
|
|
2321
|
+
jobId,
|
|
2322
|
+
status: "ok",
|
|
2323
|
+
branch: job.branch,
|
|
2324
|
+
headSha: r.headSha,
|
|
2325
|
+
pushed: r.pushed,
|
|
2326
|
+
nothingToCommit: r.nothingToCommit,
|
|
2327
|
+
commitsAhead: r.commitsAhead,
|
|
2328
|
+
...r.integration ? { integration: r.integration } : {},
|
|
2329
|
+
...pr?.prUrl ? { prUrl: pr.prUrl } : {},
|
|
2330
|
+
...pr?.prNumber !== void 0 ? { prNumber: pr.prNumber } : {},
|
|
2331
|
+
...pr?.prError ? { prError: pr.prError } : {},
|
|
2332
|
+
summary: r.nothingToCommit ? `no changes to commit; ${r.pushed ? "branch pushed" : "nothing pushed"}` : `committed ${r.headSha.slice(0, 7)} and pushed ${job.branch}`
|
|
2333
|
+
};
|
|
2334
|
+
} catch (e) {
|
|
2335
|
+
return { jobId, status: "fail", summary: `push failed: ${e.message}` };
|
|
2336
|
+
}
|
|
2337
|
+
},
|
|
2338
|
+
cancel(jobId) {
|
|
2339
|
+
void active.get(jobId)?.cancel();
|
|
2340
|
+
turnQueues.get(jobId)?.end();
|
|
2341
|
+
},
|
|
2342
|
+
enqueueTurn(jobId, turn) {
|
|
2343
|
+
turnQueues.get(jobId)?.push(turn);
|
|
2344
|
+
},
|
|
2345
|
+
endTurns(jobId) {
|
|
2346
|
+
turnQueues.get(jobId)?.end();
|
|
2347
|
+
}
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// ../../packages/edge/src/ws-client.ts
|
|
2352
|
+
var ENROLMENT_REJECTED_EXIT_CODE = 78;
|
|
2353
|
+
var log = (line) => void process.stdout.write(`${line}
|
|
2354
|
+
`);
|
|
2355
|
+
async function startEdgeNode(opts) {
|
|
2356
|
+
const rules = opts.denyTool ? [denyToolRule(opts.denyTool)] : [];
|
|
2357
|
+
const gitService = createGitService({
|
|
2358
|
+
worktreesDir: opts.worktreesDir,
|
|
2359
|
+
mirrorsDir: opts.mirrorsDir
|
|
2360
|
+
});
|
|
2361
|
+
let ws;
|
|
2362
|
+
let heartbeat;
|
|
2363
|
+
let shuttingDown = false;
|
|
2364
|
+
let onFatal;
|
|
2365
|
+
const send = (msg) => {
|
|
2366
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(encode(msg));
|
|
2367
|
+
};
|
|
2368
|
+
const MAX_RESEND = 100;
|
|
2369
|
+
const lastResults = /* @__PURE__ */ new Map();
|
|
2370
|
+
const rememberResult = (jobId, frame) => {
|
|
2371
|
+
lastResults.delete(jobId);
|
|
2372
|
+
lastResults.set(jobId, frame);
|
|
2373
|
+
if (lastResults.size > MAX_RESEND) {
|
|
2374
|
+
const oldest = lastResults.keys().next().value;
|
|
2375
|
+
if (oldest !== void 0) lastResults.delete(oldest);
|
|
2376
|
+
}
|
|
2377
|
+
};
|
|
2378
|
+
const pendingBlob = /* @__PURE__ */ new Map();
|
|
2379
|
+
let blobReqCounter = 0;
|
|
2380
|
+
const trace = {
|
|
2381
|
+
event: (frame) => send({ type: "trace-event", ...frame }),
|
|
2382
|
+
finalised: (frame) => send({ type: "trace-finalised", ...frame }),
|
|
2383
|
+
requestBlobUrl: (req) => new Promise((resolve) => {
|
|
2384
|
+
const reqId = `${req.runId}:${req.stageId}:${req.attempt}:${blobReqCounter++}`;
|
|
2385
|
+
pendingBlob.set(reqId, resolve);
|
|
2386
|
+
send({ type: "blob-put-request", reqId, ...req });
|
|
2387
|
+
setTimeout(() => {
|
|
2388
|
+
if (pendingBlob.delete(reqId)) resolve({ key: "" });
|
|
2389
|
+
}, 3e4).unref?.();
|
|
2390
|
+
})
|
|
2391
|
+
};
|
|
2392
|
+
const stageDeps = {
|
|
2393
|
+
gitService,
|
|
2394
|
+
makeRunner,
|
|
2395
|
+
...opts.servesRepoIds ? { servesRepoIds: opts.servesRepoIds } : {},
|
|
2396
|
+
...opts.tenantId ? { tenantId: opts.tenantId } : {},
|
|
2397
|
+
rules,
|
|
2398
|
+
sendProgress: (progress) => send({ type: "progress", progress }),
|
|
2399
|
+
trace,
|
|
2400
|
+
...opts.retention ? { retention: opts.retention } : {}
|
|
2401
|
+
};
|
|
2402
|
+
const stageRunner = createStageRunner(stageDeps);
|
|
2403
|
+
const nodeId = opts.nodeId ?? randomUUID();
|
|
2404
|
+
const running = /* @__PURE__ */ new Set();
|
|
2405
|
+
const onMessage = async (raw) => {
|
|
2406
|
+
const msg = decode(raw);
|
|
2407
|
+
if (msg.type === "welcome") {
|
|
2408
|
+
stageDeps.tenantId = msg.tenantId;
|
|
2409
|
+
if (opts.retention === void 0 && msg.retention) stageDeps.retention = msg.retention;
|
|
2410
|
+
if (opts.heartbeatMs === void 0 && msg.heartbeatMs > 0) {
|
|
2411
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
2412
|
+
heartbeat = setInterval(() => send({ type: "heartbeat" }), msg.heartbeatMs);
|
|
2413
|
+
}
|
|
2414
|
+
log(`EDGE_WELCOMED:${msg.name} tenant=${msg.tenantId} credentialMode=${msg.credentialMode}`);
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
if (msg.type === "blob-put-url") {
|
|
2418
|
+
const resolve = pendingBlob.get(msg.reqId);
|
|
2419
|
+
if (resolve) {
|
|
2420
|
+
pendingBlob.delete(msg.reqId);
|
|
2421
|
+
resolve({ key: msg.key, ...msg.url ? { url: msg.url } : {} });
|
|
2422
|
+
}
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
if (msg.type === "cancel") {
|
|
2426
|
+
log(`JOB_CANCEL:${msg.jobId}`);
|
|
2427
|
+
stageRunner.cancel(msg.jobId);
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
if (msg.type === "turn") {
|
|
2431
|
+
if (msg.end === "cancel") stageRunner.cancel(msg.jobId);
|
|
2432
|
+
else if (msg.end === "complete") stageRunner.endTurns(msg.jobId);
|
|
2433
|
+
else if (msg.turn) stageRunner.enqueueTurn(msg.jobId, msg.turn);
|
|
2434
|
+
log(`JOB_TURN:${msg.jobId}${msg.end ? `:${msg.end}` : ""}`);
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
if (msg.type === "push") {
|
|
2438
|
+
const { job: job2 } = msg;
|
|
2439
|
+
if (running.has(job2.jobId)) {
|
|
2440
|
+
log(`PUSH_DUPLICATE:${job2.runId} ${job2.jobId}`);
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
running.add(job2.jobId);
|
|
2444
|
+
log(`PUSH_STARTED:${job2.runId} ${job2.branch}`);
|
|
2445
|
+
try {
|
|
2446
|
+
const result = await stageRunner.runPush(job2);
|
|
2447
|
+
const frame = { type: "push-result", awakeableId: job2.awakeableId, result };
|
|
2448
|
+
rememberResult(job2.jobId, frame);
|
|
2449
|
+
send(frame);
|
|
2450
|
+
log(`PUSH_DONE:${job2.runId} ${result.status}${result.status !== "ok" ? ` - ${result.summary}` : ""}`);
|
|
2451
|
+
} catch (e) {
|
|
2452
|
+
const frame = {
|
|
2453
|
+
type: "push-result",
|
|
2454
|
+
awakeableId: job2.awakeableId,
|
|
2455
|
+
result: { jobId: job2.jobId, status: "fail", summary: `edge push error: ${e.message}` }
|
|
2456
|
+
};
|
|
2457
|
+
rememberResult(job2.jobId, frame);
|
|
2458
|
+
send(frame);
|
|
2459
|
+
log(`PUSH_ERROR:${job2.runId} ${e.message}`);
|
|
2460
|
+
} finally {
|
|
2461
|
+
running.delete(job2.jobId);
|
|
2462
|
+
}
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
if (msg.type !== "job") return;
|
|
2466
|
+
const { job } = msg;
|
|
2467
|
+
if (running.has(job.jobId)) {
|
|
2468
|
+
log(`JOB_DUPLICATE:${job.stageId} ${job.jobId}`);
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
running.add(job.jobId);
|
|
2472
|
+
log(`JOB_STARTED:${job.stageId} ${job.jobId}`);
|
|
2473
|
+
try {
|
|
2474
|
+
const result = await stageRunner.runJob(job);
|
|
2475
|
+
const frame = { type: "result", awakeableId: job.awakeableId, result };
|
|
2476
|
+
rememberResult(job.jobId, frame);
|
|
2477
|
+
send(frame);
|
|
2478
|
+
log(`JOB_DONE:${job.stageId} ${result.status}`);
|
|
2479
|
+
} catch (e) {
|
|
2480
|
+
const frame = {
|
|
2481
|
+
type: "result",
|
|
2482
|
+
awakeableId: job.awakeableId,
|
|
2483
|
+
result: { jobId: job.jobId, status: "fail", summary: `edge error: ${e.message}` }
|
|
2484
|
+
};
|
|
2485
|
+
rememberResult(job.jobId, frame);
|
|
2486
|
+
send(frame);
|
|
2487
|
+
log(`JOB_ERROR:${job.stageId} ${e.message}`);
|
|
2488
|
+
} finally {
|
|
2489
|
+
running.delete(job.jobId);
|
|
2490
|
+
}
|
|
2491
|
+
};
|
|
2492
|
+
const connect = () => {
|
|
2493
|
+
const sock = new WebSocket(opts.hubUrl);
|
|
2494
|
+
ws = sock;
|
|
2495
|
+
sock.on("open", () => {
|
|
2496
|
+
log("EDGE_CONNECTED");
|
|
2497
|
+
send({
|
|
2498
|
+
type: "hello",
|
|
2499
|
+
enrolToken: opts.enrolToken ?? "",
|
|
2500
|
+
detectedRuntimes: opts.runtimes,
|
|
2501
|
+
servesRepoIds: opts.servesRepoIds ?? [],
|
|
2502
|
+
...opts.credentialModeExplicit && opts.credentialMode ? { credentialMode: opts.credentialMode } : {},
|
|
2503
|
+
nodeId,
|
|
2504
|
+
...opts.name ? { name: opts.name } : {},
|
|
2505
|
+
os: osPlatform(),
|
|
2506
|
+
arch: osArch(),
|
|
2507
|
+
clientVersion: opts.clientVersion ?? "0.0.0"
|
|
2508
|
+
});
|
|
2509
|
+
for (const frame of lastResults.values()) send(frame);
|
|
2510
|
+
heartbeat = setInterval(() => send({ type: "heartbeat" }), opts.heartbeatMs ?? 5e3);
|
|
2511
|
+
});
|
|
2512
|
+
sock.on("message", (raw) => void onMessage(raw.toString()));
|
|
2513
|
+
sock.on("error", (e) => log(`EDGE_ERROR ${e.message}`));
|
|
2514
|
+
sock.on("close", (code, reason) => {
|
|
2515
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
2516
|
+
if (isEnrolmentRejection(code)) {
|
|
2517
|
+
shuttingDown = true;
|
|
2518
|
+
const detail = reason.toString() || "enrolment rejected";
|
|
2519
|
+
log(`EDGE_REJECTED:${code} ${detail}`);
|
|
2520
|
+
process.exitCode = ENROLMENT_REJECTED_EXIT_CODE;
|
|
2521
|
+
onFatal?.(new Error(`hub rejected edge enrolment (${code}): ${detail}`));
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (!shuttingDown) setTimeout(connect, 500);
|
|
2525
|
+
});
|
|
2526
|
+
};
|
|
2527
|
+
connect();
|
|
2528
|
+
await new Promise((resolve, reject) => {
|
|
2529
|
+
const t = setInterval(() => {
|
|
2530
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
2531
|
+
clearInterval(t);
|
|
2532
|
+
resolve();
|
|
2533
|
+
}
|
|
2534
|
+
}, 50);
|
|
2535
|
+
onFatal = (err) => {
|
|
2536
|
+
clearInterval(t);
|
|
2537
|
+
reject(err);
|
|
2538
|
+
};
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
// ../../packages/edge/src/detect-runtimes.ts
|
|
2543
|
+
import { execFile } from "child_process";
|
|
2544
|
+
var PROBES = [
|
|
2545
|
+
{ runtime: "claude-code", cmd: "claude" },
|
|
2546
|
+
{ runtime: "codex", cmd: "codex" },
|
|
2547
|
+
{ runtime: "pi", cmd: "pi" }
|
|
2548
|
+
];
|
|
2549
|
+
function probe(cmd, timeoutMs) {
|
|
2550
|
+
return new Promise((resolve) => {
|
|
2551
|
+
execFile(cmd, ["--version"], { timeout: timeoutMs }, (err, stdout) => {
|
|
2552
|
+
if (err) return resolve(void 0);
|
|
2553
|
+
const line = stdout.split("\n").map((s) => s.trim()).find(Boolean);
|
|
2554
|
+
resolve(line ?? "");
|
|
2555
|
+
});
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
async function probeRuntimeStatuses(timeoutMs = 3e3) {
|
|
2559
|
+
const versions = await Promise.all(PROBES.map((p) => probe(p.cmd, timeoutMs)));
|
|
2560
|
+
return PROBES.map((p, i) => {
|
|
2561
|
+
const version = versions[i];
|
|
2562
|
+
return version === void 0 ? { runtime: p.runtime, cmd: p.cmd, installed: false } : { runtime: p.runtime, cmd: p.cmd, installed: true, version };
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
async function detectRuntimes(timeoutMs = 3e3) {
|
|
2566
|
+
const statuses = await probeRuntimeStatuses(timeoutMs);
|
|
2567
|
+
return statuses.filter((s) => s.installed).map((s) => s.runtime);
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
// ../../packages/edge/src/hub-probe.ts
|
|
2571
|
+
import { arch as osArch2, platform as osPlatform2 } from "os";
|
|
2572
|
+
import { WebSocket as WebSocket2 } from "ws";
|
|
2573
|
+
import { decode as decode2, encode as encode2, isEnrolmentRejection as isEnrolmentRejection2 } from "@dahrk/contracts";
|
|
2574
|
+
function enrolmentDetail(code) {
|
|
2575
|
+
switch (code) {
|
|
2576
|
+
case 4400:
|
|
2577
|
+
return "no enrolment token was presented";
|
|
2578
|
+
case 4401:
|
|
2579
|
+
return "the enrolment token is invalid, expired, or revoked";
|
|
2580
|
+
case 4404:
|
|
2581
|
+
return "the token verified but its pool no longer exists";
|
|
2582
|
+
case 4503:
|
|
2583
|
+
return "the hub has no enrolment secret configured";
|
|
2584
|
+
default:
|
|
2585
|
+
return `enrolment rejected (${code})`;
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
function probeHub(opts) {
|
|
2589
|
+
const {
|
|
2590
|
+
hubUrl,
|
|
2591
|
+
enrolToken,
|
|
2592
|
+
runtimes = [],
|
|
2593
|
+
nodeId = "dahrk-doctor",
|
|
2594
|
+
clientVersion = "0.0.0",
|
|
2595
|
+
timeoutMs = 8e3
|
|
2596
|
+
} = opts;
|
|
2597
|
+
return new Promise((resolve) => {
|
|
2598
|
+
let settled = false;
|
|
2599
|
+
let opened = false;
|
|
2600
|
+
let ws;
|
|
2601
|
+
const done = (result) => {
|
|
2602
|
+
if (settled) return;
|
|
2603
|
+
settled = true;
|
|
2604
|
+
clearTimeout(timer);
|
|
2605
|
+
try {
|
|
2606
|
+
ws.terminate();
|
|
2607
|
+
} catch {
|
|
2608
|
+
}
|
|
2609
|
+
resolve(result);
|
|
2610
|
+
};
|
|
2611
|
+
const timer = setTimeout(
|
|
2612
|
+
() => done({ ok: false, reason: "timeout", detail: `no welcome within ${timeoutMs}ms` }),
|
|
2613
|
+
timeoutMs
|
|
2614
|
+
);
|
|
2615
|
+
timer.unref?.();
|
|
2616
|
+
try {
|
|
2617
|
+
ws = new WebSocket2(hubUrl);
|
|
2618
|
+
} catch (e) {
|
|
2619
|
+
done({ ok: false, reason: "unreachable", detail: e.message });
|
|
2620
|
+
return;
|
|
2621
|
+
}
|
|
2622
|
+
ws.on("open", () => {
|
|
2623
|
+
opened = true;
|
|
2624
|
+
ws.send(
|
|
2625
|
+
encode2({
|
|
2626
|
+
type: "hello",
|
|
2627
|
+
enrolToken: enrolToken ?? "",
|
|
2628
|
+
detectedRuntimes: runtimes,
|
|
2629
|
+
servesRepoIds: [],
|
|
2630
|
+
nodeId,
|
|
2631
|
+
os: osPlatform2(),
|
|
2632
|
+
arch: osArch2(),
|
|
2633
|
+
clientVersion
|
|
2634
|
+
})
|
|
2635
|
+
);
|
|
2636
|
+
});
|
|
2637
|
+
ws.on("message", (raw) => {
|
|
2638
|
+
let msg;
|
|
2639
|
+
try {
|
|
2640
|
+
msg = decode2(raw.toString());
|
|
2641
|
+
} catch {
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
if (msg.type === "welcome") {
|
|
2645
|
+
done({
|
|
2646
|
+
ok: true,
|
|
2647
|
+
nodeId: msg.nodeId,
|
|
2648
|
+
name: msg.name,
|
|
2649
|
+
tenantId: msg.tenantId,
|
|
2650
|
+
credentialMode: msg.credentialMode
|
|
2651
|
+
});
|
|
2652
|
+
}
|
|
2653
|
+
});
|
|
2654
|
+
ws.on("error", (e) => {
|
|
2655
|
+
if (!opened) done({ ok: false, reason: "unreachable", detail: e.message });
|
|
2656
|
+
});
|
|
2657
|
+
ws.on("close", (code, reason) => {
|
|
2658
|
+
const detail = reason?.toString() || "";
|
|
2659
|
+
if (isEnrolmentRejection2(code)) {
|
|
2660
|
+
done({ ok: false, reason: "rejected", code, detail: detail || enrolmentDetail(code) });
|
|
2661
|
+
} else if (!opened) {
|
|
2662
|
+
done({ ok: false, reason: "unreachable", detail: detail || `could not connect (${code})` });
|
|
2663
|
+
} else {
|
|
2664
|
+
done({ ok: false, reason: "closed", code, detail: detail || `hub closed the socket (${code})` });
|
|
2665
|
+
}
|
|
2666
|
+
});
|
|
2667
|
+
});
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
// src/cli.ts
|
|
2671
|
+
import { parseArgs } from "util";
|
|
2672
|
+
var COMMANDS = /* @__PURE__ */ new Set(["start", "doctor"]);
|
|
2673
|
+
var isCommand = (s) => COMMANDS.has(s);
|
|
2674
|
+
function parseCli(argv) {
|
|
2675
|
+
const [first, ...rest] = argv;
|
|
2676
|
+
if (first === "help" || first === "--help" || first === "-h") {
|
|
2677
|
+
const sub = rest[0];
|
|
2678
|
+
return sub && isCommand(sub) ? { kind: "help", command: sub } : { kind: "help" };
|
|
2679
|
+
}
|
|
2680
|
+
if (first === "version" || first === "--version" || first === "-v") return { kind: "version" };
|
|
2681
|
+
let command = "start";
|
|
2682
|
+
let flagArgs = argv;
|
|
2683
|
+
if (first !== void 0 && !first.startsWith("-")) {
|
|
2684
|
+
if (!isCommand(first)) {
|
|
2685
|
+
return { kind: "error", message: `unknown command: ${first}` };
|
|
2686
|
+
}
|
|
2687
|
+
command = first;
|
|
2688
|
+
flagArgs = rest;
|
|
2689
|
+
}
|
|
2690
|
+
let values;
|
|
2691
|
+
try {
|
|
2692
|
+
({ values } = parseArgs({
|
|
2693
|
+
args: flagArgs,
|
|
2694
|
+
options: {
|
|
2695
|
+
token: { type: "string" },
|
|
2696
|
+
name: { type: "string" },
|
|
2697
|
+
"hub-url": { type: "string" },
|
|
2698
|
+
ephemeral: { type: "boolean", default: false },
|
|
2699
|
+
help: { type: "boolean", default: false }
|
|
2700
|
+
},
|
|
2701
|
+
allowPositionals: false
|
|
2702
|
+
}));
|
|
2703
|
+
} catch (e) {
|
|
2704
|
+
return { kind: "error", message: e.message };
|
|
2705
|
+
}
|
|
2706
|
+
if (values.help) return { kind: "help", command };
|
|
2707
|
+
const flags = {
|
|
2708
|
+
...values.token ? { token: values.token } : {},
|
|
2709
|
+
...values.name ? { name: values.name } : {},
|
|
2710
|
+
...values["hub-url"] ? { hubUrl: values["hub-url"] } : {},
|
|
2711
|
+
ephemeral: values.ephemeral ?? false
|
|
2712
|
+
};
|
|
2713
|
+
return { kind: command, flags };
|
|
2714
|
+
}
|
|
2715
|
+
function usage(bin, command) {
|
|
2716
|
+
if (command === "start") {
|
|
2717
|
+
return [
|
|
2718
|
+
`Usage: ${bin} start --token <token> [options]`,
|
|
2719
|
+
"",
|
|
2720
|
+
"Run the edge node: dial the hub over WebSocket and serve Jobs in git worktrees.",
|
|
2721
|
+
"",
|
|
2722
|
+
"Options:",
|
|
2723
|
+
" --token <token> Enrolment token (required; or set DAHRK_ENROL_TOKEN).",
|
|
2724
|
+
" --hub-url <url> Hub WebSocket URL (or set DAHRK_HUB_URL).",
|
|
2725
|
+
" --name <name> Display-name override (else the hub assigns one).",
|
|
2726
|
+
" --ephemeral Do not persist a node id; mint a throwaway one (CI / one-shot)."
|
|
2727
|
+
].join("\n");
|
|
2728
|
+
}
|
|
2729
|
+
if (command === "doctor") {
|
|
2730
|
+
return [
|
|
2731
|
+
`Usage: ${bin} doctor [options]`,
|
|
2732
|
+
"",
|
|
2733
|
+
"Run preflight checks: Node version, installed runtimes, hub reachability, token validity.",
|
|
2734
|
+
"",
|
|
2735
|
+
"Options:",
|
|
2736
|
+
" --token <token> Enrolment token to validate (or set DAHRK_ENROL_TOKEN).",
|
|
2737
|
+
" --hub-url <url> Hub WebSocket URL to reach (or set DAHRK_HUB_URL)."
|
|
2738
|
+
].join("\n");
|
|
2739
|
+
}
|
|
2740
|
+
return [
|
|
2741
|
+
`Usage: ${bin} <command> [options]`,
|
|
2742
|
+
"",
|
|
2743
|
+
"Commands:",
|
|
2744
|
+
" start Run the edge node (default). Needs a --token and a hub URL.",
|
|
2745
|
+
" doctor Preflight checks: Node, runtimes, hub reachability, token validity.",
|
|
2746
|
+
" version Print the client version.",
|
|
2747
|
+
" help Show this help, or `help <command>` for a command's options.",
|
|
2748
|
+
"",
|
|
2749
|
+
`Run \`${bin} help <command>\` for command-specific options.`
|
|
2750
|
+
].join("\n");
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
// src/doctor.ts
|
|
2754
|
+
var MIN_NODE_MAJOR = 22;
|
|
2755
|
+
var TAG = { pass: "[PASS]", warn: "[WARN]", fail: "[FAIL]" };
|
|
2756
|
+
function checkNode(nodeVersion) {
|
|
2757
|
+
const major = Number.parseInt(nodeVersion.replace(/^v/, "").split(".")[0] ?? "", 10);
|
|
2758
|
+
if (Number.isNaN(major)) {
|
|
2759
|
+
return { status: "warn", label: "Node version", detail: `could not parse "${nodeVersion}"` };
|
|
2760
|
+
}
|
|
2761
|
+
return major >= MIN_NODE_MAJOR ? { status: "pass", label: "Node version", detail: `v${major} (>= ${MIN_NODE_MAJOR})` } : {
|
|
2762
|
+
status: "fail",
|
|
2763
|
+
label: "Node version",
|
|
2764
|
+
detail: `v${major} is too old; Dahrk needs Node ${MIN_NODE_MAJOR}+`
|
|
2765
|
+
};
|
|
2766
|
+
}
|
|
2767
|
+
function checkRuntimes(statuses) {
|
|
2768
|
+
const installed = statuses.filter((s) => s.installed);
|
|
2769
|
+
if (installed.length === 0) {
|
|
2770
|
+
return {
|
|
2771
|
+
status: "warn",
|
|
2772
|
+
label: "Agent runtimes",
|
|
2773
|
+
detail: "none detected (claude/codex/pi not on PATH); the node will serve no Jobs. Install one or set DAHRK_RUNTIMES."
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2776
|
+
const detail = installed.map((s) => `${s.runtime}${s.version ? ` (${s.version})` : ""}`).join(", ");
|
|
2777
|
+
return { status: "pass", label: "Agent runtimes", detail };
|
|
2778
|
+
}
|
|
2779
|
+
function checkHub(hubUrl, probe2) {
|
|
2780
|
+
if (!hubUrl) {
|
|
2781
|
+
return {
|
|
2782
|
+
status: "fail",
|
|
2783
|
+
label: "Hub reachability",
|
|
2784
|
+
detail: "no hub URL configured (set DAHRK_HUB_URL or pass --hub-url)"
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
if (!probe2) {
|
|
2788
|
+
return { status: "warn", label: "Hub reachability", detail: `not checked (${hubUrl})` };
|
|
2789
|
+
}
|
|
2790
|
+
if (probe2.ok) {
|
|
2791
|
+
return { status: "pass", label: "Hub reachability", detail: `connected to ${hubUrl}` };
|
|
2792
|
+
}
|
|
2793
|
+
switch (probe2.reason) {
|
|
2794
|
+
case "rejected":
|
|
2795
|
+
return {
|
|
2796
|
+
status: "pass",
|
|
2797
|
+
label: "Hub reachability",
|
|
2798
|
+
detail: `reachable at ${hubUrl} (enrolment rejected - see token check)`
|
|
2799
|
+
};
|
|
2800
|
+
case "unreachable":
|
|
2801
|
+
return { status: "fail", label: "Hub reachability", detail: `cannot reach ${hubUrl}: ${probe2.detail}` };
|
|
2802
|
+
case "timeout":
|
|
2803
|
+
return {
|
|
2804
|
+
status: "fail",
|
|
2805
|
+
label: "Hub reachability",
|
|
2806
|
+
detail: `${hubUrl} connected but sent no welcome: ${probe2.detail}`
|
|
2807
|
+
};
|
|
2808
|
+
case "closed":
|
|
2809
|
+
return {
|
|
2810
|
+
status: "fail",
|
|
2811
|
+
label: "Hub reachability",
|
|
2812
|
+
detail: `${hubUrl} closed the socket (${probe2.code}): ${probe2.detail}`
|
|
2813
|
+
};
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
function checkToken(tokenPresent, hubUrl, probe2) {
|
|
2817
|
+
if (!tokenPresent) {
|
|
2818
|
+
return {
|
|
2819
|
+
status: "fail",
|
|
2820
|
+
label: "Enrolment token",
|
|
2821
|
+
detail: "no token (pass --token or set DAHRK_ENROL_TOKEN)"
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
if (!hubUrl || !probe2) {
|
|
2825
|
+
return { status: "warn", label: "Enrolment token", detail: "present but not verified (no hub to check against)" };
|
|
2826
|
+
}
|
|
2827
|
+
if (probe2.ok) {
|
|
2828
|
+
return { status: "pass", label: "Enrolment token", detail: `valid (tenant ${probe2.tenantId})` };
|
|
2829
|
+
}
|
|
2830
|
+
if (probe2.reason === "rejected") {
|
|
2831
|
+
switch (probe2.code) {
|
|
2832
|
+
case 4400:
|
|
2833
|
+
return { status: "fail", label: "Enrolment token", detail: `hub saw no token: ${probe2.detail}` };
|
|
2834
|
+
case 4401:
|
|
2835
|
+
return { status: "fail", label: "Enrolment token", detail: "invalid, expired, or revoked" };
|
|
2836
|
+
case 4404:
|
|
2837
|
+
return { status: "fail", label: "Enrolment token", detail: "the token's pool no longer exists" };
|
|
2838
|
+
case 4503:
|
|
2839
|
+
return {
|
|
2840
|
+
status: "warn",
|
|
2841
|
+
label: "Enrolment token",
|
|
2842
|
+
detail: "cannot verify: the hub has no enrolment secret configured"
|
|
2843
|
+
};
|
|
2844
|
+
default:
|
|
2845
|
+
return { status: "fail", label: "Enrolment token", detail: probe2.detail };
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
return { status: "warn", label: "Enrolment token", detail: "present but unverified (hub not reachable)" };
|
|
2849
|
+
}
|
|
2850
|
+
function formatReport(checks) {
|
|
2851
|
+
const failed = checks.filter((c) => c.status === "fail").length;
|
|
2852
|
+
const warned = checks.filter((c) => c.status === "warn").length;
|
|
2853
|
+
const lines = checks.map((c) => `${TAG[c.status]} ${c.label}${c.detail ? `: ${c.detail}` : ""}`);
|
|
2854
|
+
const summary = failed > 0 ? `FAIL - ${failed} check${failed === 1 ? "" : "s"} failed${warned ? `, ${warned} warning${warned === 1 ? "" : "s"}` : ""}.` : warned > 0 ? `PASS with ${warned} warning${warned === 1 ? "" : "s"}.` : "PASS - all checks green.";
|
|
2855
|
+
return ["dahrk doctor", "", ...lines, "", summary].join("\n");
|
|
2856
|
+
}
|
|
2857
|
+
var defaultDeps = () => ({
|
|
2858
|
+
nodeVersion: process.versions.node,
|
|
2859
|
+
probeRuntimes: probeRuntimeStatuses,
|
|
2860
|
+
probeHub,
|
|
2861
|
+
out: (line) => void process.stdout.write(`${line}
|
|
2862
|
+
`)
|
|
2863
|
+
});
|
|
2864
|
+
async function runDoctor(inputs, deps = {}) {
|
|
2865
|
+
const d = { ...defaultDeps(), ...deps };
|
|
2866
|
+
const statuses = await d.probeRuntimes();
|
|
2867
|
+
const installed = statuses.filter((s) => s.installed).map((s) => s.runtime);
|
|
2868
|
+
const probe2 = inputs.hubUrl ? await d.probeHub({
|
|
2869
|
+
hubUrl: inputs.hubUrl,
|
|
2870
|
+
...inputs.token ? { enrolToken: inputs.token } : {},
|
|
2871
|
+
runtimes: installed,
|
|
2872
|
+
...inputs.clientVersion ? { clientVersion: inputs.clientVersion } : {}
|
|
2873
|
+
}) : void 0;
|
|
2874
|
+
const checks = [
|
|
2875
|
+
checkNode(d.nodeVersion),
|
|
2876
|
+
checkRuntimes(statuses),
|
|
2877
|
+
checkHub(inputs.hubUrl, probe2),
|
|
2878
|
+
checkToken(Boolean(inputs.token), inputs.hubUrl, probe2)
|
|
2879
|
+
];
|
|
2880
|
+
d.out(formatReport(checks));
|
|
2881
|
+
return checks.some((c) => c.status === "fail") ? 1 : 0;
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
// src/main.ts
|
|
2885
|
+
var CLIENT_VERSION = "0.1.0";
|
|
2886
|
+
var DEFAULT_HUB_URL = "wss://hub.dahrk.net";
|
|
2887
|
+
var list = (v) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
2888
|
+
var RUNTIMES = ["claude-code", "codex", "pi"];
|
|
2889
|
+
var isRuntime = (r) => RUNTIMES.includes(r);
|
|
2890
|
+
function stateDir(env) {
|
|
2891
|
+
return env.DAHRK_STATE_DIR ?? join7(homedir2(), ".dahrk");
|
|
2892
|
+
}
|
|
2893
|
+
function legacyStateDir(env) {
|
|
2894
|
+
return env.DAHRK_STATE_DIR ? void 0 : join7(homedir2(), ".skakel");
|
|
2895
|
+
}
|
|
2896
|
+
function readNodeId(file) {
|
|
2897
|
+
if (!existsSync4(file)) return void 0;
|
|
2898
|
+
try {
|
|
2899
|
+
const parsed = JSON.parse(readFileSync5(file, "utf8"));
|
|
2900
|
+
if (typeof parsed.nodeId === "string" && parsed.nodeId) return parsed.nodeId;
|
|
2901
|
+
} catch {
|
|
2902
|
+
}
|
|
2903
|
+
return void 0;
|
|
2904
|
+
}
|
|
2905
|
+
function resolveNodeId(env, opts = {}) {
|
|
2906
|
+
if (env.DAHRK_NODE_ID) return env.DAHRK_NODE_ID;
|
|
2907
|
+
if (opts.ephemeral) return randomUUID2();
|
|
2908
|
+
const dir = stateDir(env);
|
|
2909
|
+
const file = join7(dir, "node.json");
|
|
2910
|
+
const existing = readNodeId(file);
|
|
2911
|
+
if (existing) return existing;
|
|
2912
|
+
const legacy = legacyStateDir(env);
|
|
2913
|
+
if (legacy) {
|
|
2914
|
+
const legacyId = readNodeId(join7(legacy, "node.json"));
|
|
2915
|
+
if (legacyId) return legacyId;
|
|
2916
|
+
}
|
|
2917
|
+
const nodeId = randomUUID2();
|
|
2918
|
+
try {
|
|
2919
|
+
mkdirSync6(dir, { recursive: true });
|
|
2920
|
+
writeFileSync6(file, `${JSON.stringify({ nodeId }, null, 2)}
|
|
2921
|
+
`);
|
|
2922
|
+
} catch (e) {
|
|
2923
|
+
console.warn(`could not persist node id to ${file}: ${e.message}`);
|
|
2924
|
+
}
|
|
2925
|
+
return nodeId;
|
|
2926
|
+
}
|
|
2927
|
+
async function resolveRuntimes(env) {
|
|
2928
|
+
const override = list(env.DAHRK_RUNTIMES).filter(isRuntime);
|
|
2929
|
+
if (override.length > 0) return override;
|
|
2930
|
+
if ((env.DAHRK_RUNNER ?? "real") === "mock") return ["claude-code"];
|
|
2931
|
+
return detectRuntimes();
|
|
2932
|
+
}
|
|
2933
|
+
function buildEdgeOptions(env, resolved) {
|
|
2934
|
+
const hubUrl = env.DAHRK_HUB_URL ?? DEFAULT_HUB_URL;
|
|
2935
|
+
const servesRepoIds = list(env.DAHRK_REPOS);
|
|
2936
|
+
const credentialModeExplicit = env.DAHRK_CREDENTIAL_MODE != null;
|
|
2937
|
+
const credentialMode = env.DAHRK_CREDENTIAL_MODE === "brokered" ? "brokered" : "ambient";
|
|
2938
|
+
const envRuntimes = list(env.DAHRK_RUNTIMES).filter(isRuntime);
|
|
2939
|
+
const runtimes = resolved ? resolved.runtimes : envRuntimes.length > 0 ? envRuntimes : ["claude-code"];
|
|
2940
|
+
const nodeId = resolved?.nodeId ?? env.DAHRK_NODE_ID;
|
|
2941
|
+
const maxRuns = env.DAHRK_RETENTION_MAX_RUNS;
|
|
2942
|
+
const maxAgeMs = env.DAHRK_RETENTION_MAX_AGE_MS;
|
|
2943
|
+
const retention = maxRuns || maxAgeMs ? {
|
|
2944
|
+
...maxRuns ? { maxRuns: Number(maxRuns) } : {},
|
|
2945
|
+
...maxAgeMs ? { maxAgeMs: Number(maxAgeMs) } : {}
|
|
2946
|
+
} : void 0;
|
|
2947
|
+
return {
|
|
2948
|
+
hubUrl,
|
|
2949
|
+
...servesRepoIds.length > 0 ? { servesRepoIds } : {},
|
|
2950
|
+
runtimes,
|
|
2951
|
+
credentialMode,
|
|
2952
|
+
credentialModeExplicit,
|
|
2953
|
+
// Identity: the persisted UUID (or an explicit id); the hub assigns a display name unless --name /
|
|
2954
|
+
// DAHRK_NODE_NAME overrides it.
|
|
2955
|
+
...nodeId ? { nodeId } : {},
|
|
2956
|
+
...env.DAHRK_NODE_NAME ? { name: env.DAHRK_NODE_NAME } : {},
|
|
2957
|
+
...resolved ? { clientVersion: resolved.clientVersion } : {},
|
|
2958
|
+
// Enrolment token (required); tenant is derived hub-side from its pool.
|
|
2959
|
+
...env.DAHRK_ENROL_TOKEN ? { enrolToken: env.DAHRK_ENROL_TOKEN } : {},
|
|
2960
|
+
// DAHRK_TENANT_ID is no longer required (tenant comes from `welcome`) but is still honoured as a
|
|
2961
|
+
// defence-in-depth override for the managed profile.
|
|
2962
|
+
...env.DAHRK_TENANT_ID ? { tenantId: env.DAHRK_TENANT_ID } : {},
|
|
2963
|
+
worktreesDir: env.DAHRK_WORKTREES_DIR,
|
|
2964
|
+
mirrorsDir: env.DAHRK_MIRRORS_DIR,
|
|
2965
|
+
denyTool: env.DAHRK_DENY_TOOL,
|
|
2966
|
+
heartbeatMs: env.DAHRK_HEARTBEAT_MS ? Number(env.DAHRK_HEARTBEAT_MS) : void 0,
|
|
2967
|
+
...retention ? { retention } : {}
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
function applyEnvAliases(env) {
|
|
2971
|
+
const merged = { ...env };
|
|
2972
|
+
for (const [key, value] of Object.entries(env)) {
|
|
2973
|
+
if (key.startsWith("SKAKEL_") && value !== void 0) {
|
|
2974
|
+
const dahrkKey = `DAHRK_${key.slice("SKAKEL_".length)}`;
|
|
2975
|
+
merged[dahrkKey] ??= value;
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
return merged;
|
|
2979
|
+
}
|
|
2980
|
+
function envWithFlags(env, flags) {
|
|
2981
|
+
const merged = applyEnvAliases(env);
|
|
2982
|
+
if (flags.token) merged.DAHRK_ENROL_TOKEN = flags.token;
|
|
2983
|
+
if (flags.name) merged.DAHRK_NODE_NAME = flags.name;
|
|
2984
|
+
if (flags.hubUrl) merged.DAHRK_HUB_URL = flags.hubUrl;
|
|
2985
|
+
return merged;
|
|
2986
|
+
}
|
|
2987
|
+
async function start(flags) {
|
|
2988
|
+
const env = envWithFlags(process.env, flags);
|
|
2989
|
+
const nodeId = resolveNodeId(env, { ephemeral: flags.ephemeral });
|
|
2990
|
+
const runtimes = await resolveRuntimes(env);
|
|
2991
|
+
if (runtimes.length === 0) {
|
|
2992
|
+
console.warn(
|
|
2993
|
+
"no agent runtimes detected on this host (claude/codex/pi not on PATH); the node will advertise none and serve no Jobs. Install a runtime or set DAHRK_RUNTIMES to override. Run `dahrk doctor` to check."
|
|
2994
|
+
);
|
|
2995
|
+
}
|
|
2996
|
+
const resolved = { nodeId, runtimes, clientVersion: CLIENT_VERSION };
|
|
2997
|
+
await startEdgeNode(buildEdgeOptions(env, resolved));
|
|
2998
|
+
}
|
|
2999
|
+
async function main() {
|
|
3000
|
+
const invoked = basename(process.argv[1] ?? "");
|
|
3001
|
+
const bin = !invoked || invoked.startsWith("main.") ? "dahrk" : invoked;
|
|
3002
|
+
const parsed = parseCli(process.argv.slice(2));
|
|
3003
|
+
switch (parsed.kind) {
|
|
3004
|
+
case "error":
|
|
3005
|
+
console.error(`${parsed.message}
|
|
3006
|
+
`);
|
|
3007
|
+
console.error(usage(bin));
|
|
3008
|
+
process.exit(2);
|
|
3009
|
+
break;
|
|
3010
|
+
case "help":
|
|
3011
|
+
console.log(usage(bin, parsed.command));
|
|
3012
|
+
break;
|
|
3013
|
+
case "version":
|
|
3014
|
+
console.log(CLIENT_VERSION);
|
|
3015
|
+
break;
|
|
3016
|
+
case "doctor": {
|
|
3017
|
+
const env = envWithFlags(process.env, parsed.flags);
|
|
3018
|
+
process.exitCode = await runDoctor({
|
|
3019
|
+
hubUrl: env.DAHRK_HUB_URL,
|
|
3020
|
+
token: env.DAHRK_ENROL_TOKEN,
|
|
3021
|
+
clientVersion: CLIENT_VERSION
|
|
3022
|
+
});
|
|
3023
|
+
break;
|
|
3024
|
+
}
|
|
3025
|
+
case "start":
|
|
3026
|
+
await start(parsed.flags);
|
|
3027
|
+
break;
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
var invokedAsEntrypoint = (() => {
|
|
3031
|
+
const argv1 = process.argv[1];
|
|
3032
|
+
if (!argv1) return false;
|
|
3033
|
+
try {
|
|
3034
|
+
return pathToFileURL(realpathSync(argv1)).href === import.meta.url;
|
|
3035
|
+
} catch {
|
|
3036
|
+
return false;
|
|
3037
|
+
}
|
|
3038
|
+
})();
|
|
3039
|
+
if (invokedAsEntrypoint) {
|
|
3040
|
+
main().catch((err) => {
|
|
3041
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
3042
|
+
process.exit(process.exitCode || 1);
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
3045
|
+
export {
|
|
3046
|
+
DEFAULT_HUB_URL,
|
|
3047
|
+
buildEdgeOptions,
|
|
3048
|
+
resolveNodeId,
|
|
3049
|
+
resolveRuntimes
|
|
3050
|
+
};
|