@awak-app/simy-cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -4
- package/package.json +16 -3
- package/src/agent.js +744 -56
- package/src/backend-executable.js +44 -0
- package/src/browser.js +59 -0
- package/src/console/app.js +1042 -0
- package/src/console/commands.js +100 -0
- package/src/console/index.js +25 -0
- package/src/index.js +44 -3
- package/src/local-attachments.js +270 -0
- package/src/orchestrator/contract.js +1 -0
- package/src/orchestrator/independent-audit.js +25 -0
- package/src/orchestrator/index.js +1 -1
- package/src/orchestrator/instruction.js +27 -1
- package/src/orchestrator/loop.js +61 -25
- package/src/orchestrator/presentation.js +189 -0
- package/src/orchestrator/result.js +11 -0
- package/src/provider-stream.js +310 -0
- package/src/repository-inventory.js +186 -0
- package/src/run-registry.js +44 -0
- package/src/runner.js +525 -64
- package/src/session-store.js +46 -17
- package/src/web-api.js +66 -0
- package/src/web-origin.js +46 -0
- package/src/workspace-context.js +37 -0
package/src/runner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
|
-
import { EventEmitter } from "node:events";
|
|
2
|
+
import { EventEmitter, once } from "node:events";
|
|
3
3
|
import { access } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
@@ -18,32 +18,52 @@ import {
|
|
|
18
18
|
redactExecutionLogs,
|
|
19
19
|
redactExecutionText,
|
|
20
20
|
} from "./orchestrator/execution-io.js";
|
|
21
|
+
import { summarizeCodingLoopEvent } from "./orchestrator/presentation.js";
|
|
22
|
+
import { createProviderStreamDecoder } from "./provider-stream.js";
|
|
23
|
+
import { resolveBackendExecutable } from "./backend-executable.js";
|
|
24
|
+
import {
|
|
25
|
+
attachmentDescriptorForLedger,
|
|
26
|
+
cleanupRunAttachments,
|
|
27
|
+
markAttachmentsCleaned,
|
|
28
|
+
markAttachmentsCleanupFailed,
|
|
29
|
+
markAttachmentsDelivered,
|
|
30
|
+
} from "./local-attachments.js";
|
|
31
|
+
import { LocalRunRegistry } from "./run-registry.js";
|
|
32
|
+
import { webApiHeaders, webApiUrl } from "./web-api.js";
|
|
33
|
+
import { normalizeGitHubRemote } from "./workspace-context.js";
|
|
21
34
|
|
|
22
35
|
const execFileAsync = promisify(execFile);
|
|
36
|
+
const MAX_LOCAL_LOG_LINES = 1_000;
|
|
23
37
|
const TERMINAL_STATES = new Set([
|
|
24
38
|
"merge_ready",
|
|
25
39
|
"pr_ready_for_review",
|
|
26
40
|
"failed",
|
|
27
41
|
"blocked",
|
|
28
42
|
"waiting_human",
|
|
43
|
+
"stopped",
|
|
29
44
|
]);
|
|
45
|
+
const RESUMABLE_STATES = new Set([
|
|
46
|
+
"waiting_human",
|
|
47
|
+
"pr_ready_for_review",
|
|
48
|
+
"blocked",
|
|
49
|
+
"failed",
|
|
50
|
+
"stopped",
|
|
51
|
+
]);
|
|
52
|
+
const INTERRUPTED_STATES = new Set([
|
|
53
|
+
"queued",
|
|
54
|
+
"risk_classifying",
|
|
55
|
+
"chartering",
|
|
56
|
+
"dispatching",
|
|
57
|
+
"coding",
|
|
58
|
+
"collecting_evidence",
|
|
59
|
+
"auditing",
|
|
60
|
+
"independent_auditing",
|
|
61
|
+
"checking_pr",
|
|
62
|
+
"re_instructing",
|
|
63
|
+
]);
|
|
64
|
+
const RESTORABLE_STATES = new Set([...RESUMABLE_STATES, ...INTERRUPTED_STATES]);
|
|
30
65
|
|
|
31
|
-
export
|
|
32
|
-
#runs = new Map();
|
|
33
|
-
|
|
34
|
-
create(run) {
|
|
35
|
-
if (this.#runs.has(run.id)) throw new Error(`Run ${run.id} already exists.`);
|
|
36
|
-
this.#runs.set(run.id, run);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
get(runId) {
|
|
40
|
-
return this.#runs.get(runId) ?? null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
has(runId) {
|
|
44
|
-
return this.#runs.has(runId);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
66
|
+
export { LocalRunRegistry };
|
|
47
67
|
|
|
48
68
|
export function createRun({ runId, request, session, apiOrigin }) {
|
|
49
69
|
const emitter = new EventEmitter();
|
|
@@ -57,6 +77,12 @@ export function createRun({ runId, request, session, apiOrigin }) {
|
|
|
57
77
|
startedAt: null,
|
|
58
78
|
completedAt: null,
|
|
59
79
|
child: null,
|
|
80
|
+
operation: null,
|
|
81
|
+
controlState: "queued",
|
|
82
|
+
stopRequested: false,
|
|
83
|
+
logs: [],
|
|
84
|
+
pendingGuidance: [],
|
|
85
|
+
resumeCount: 0,
|
|
60
86
|
emitter,
|
|
61
87
|
pendingLedgerUpdate: null,
|
|
62
88
|
ledgerUpdateRunning: false,
|
|
@@ -71,13 +97,312 @@ export function createRun({ runId, request, session, apiOrigin }) {
|
|
|
71
97
|
};
|
|
72
98
|
}
|
|
73
99
|
|
|
100
|
+
export function restoreRun({ snapshot, session, apiOrigin, localPath = null }) {
|
|
101
|
+
if (!snapshot || typeof snapshot !== "object" || !snapshot.charter) {
|
|
102
|
+
throw new Error("Cannot restore an invalid coding loop snapshot.");
|
|
103
|
+
}
|
|
104
|
+
const charter = snapshot.charter;
|
|
105
|
+
const run = createRun({
|
|
106
|
+
runId: String(snapshot.id || ""),
|
|
107
|
+
session,
|
|
108
|
+
apiOrigin,
|
|
109
|
+
request: {
|
|
110
|
+
backend: charter.backend === "claude" ? "claude" : "codex",
|
|
111
|
+
audit_backend:
|
|
112
|
+
charter.audit_backend === "claude" || charter.audit_backend === "codex"
|
|
113
|
+
? charter.audit_backend
|
|
114
|
+
: null,
|
|
115
|
+
requirement: String(charter.requirement || ""),
|
|
116
|
+
repository: String(charter.repository || ""),
|
|
117
|
+
local_path: localPath,
|
|
118
|
+
base_branch: String(charter.base_branch || "dev"),
|
|
119
|
+
max_attempts: charter.max_attempts,
|
|
120
|
+
ui_evidence_root: String(charter.ui_evidence_root || ""),
|
|
121
|
+
acceptance_criteria: Array.isArray(charter.acceptance_criteria)
|
|
122
|
+
? charter.acceptance_criteria
|
|
123
|
+
: [],
|
|
124
|
+
expected_tests: Array.isArray(charter.expected_tests) ? charter.expected_tests : [],
|
|
125
|
+
expected_evidence: Array.isArray(charter.expected_evidence)
|
|
126
|
+
? charter.expected_evidence
|
|
127
|
+
: [],
|
|
128
|
+
required_checks: Array.isArray(charter.required_checks) ? charter.required_checks : [],
|
|
129
|
+
require_human_approval: charter.require_human_approval !== false,
|
|
130
|
+
must_not: Array.isArray(charter.must_not) ? charter.must_not : [],
|
|
131
|
+
proposal_id: typeof charter.proposal_id === "string" ? charter.proposal_id : null,
|
|
132
|
+
attachments: [],
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const restoredSnapshot = structuredClone(snapshot);
|
|
136
|
+
const lifecycleState = restoredSnapshot.metadata?.pr_lifecycle_state;
|
|
137
|
+
const persistedState =
|
|
138
|
+
typeof lifecycleState === "string" && RESTORABLE_STATES.has(lifecycleState)
|
|
139
|
+
? lifecycleState
|
|
140
|
+
: restoredSnapshot.state;
|
|
141
|
+
if (!RESTORABLE_STATES.has(persistedState)) {
|
|
142
|
+
throw new Error(`Coding loop ${run.id} is not in a restorable state.`);
|
|
143
|
+
}
|
|
144
|
+
const restoredFromState = INTERRUPTED_STATES.has(persistedState) ? persistedState : null;
|
|
145
|
+
const restoredState = restoredFromState ? "waiting_human" : persistedState;
|
|
146
|
+
if (restoredFromState) {
|
|
147
|
+
restoredSnapshot.events = Array.isArray(restoredSnapshot.events)
|
|
148
|
+
? restoredSnapshot.events
|
|
149
|
+
: [];
|
|
150
|
+
restoredSnapshot.events.push({
|
|
151
|
+
state: "waiting_human",
|
|
152
|
+
message: "Local execution was interrupted and is ready to continue.",
|
|
153
|
+
detail: {
|
|
154
|
+
code: "local_execution_interrupted",
|
|
155
|
+
interrupted_state: restoredFromState,
|
|
156
|
+
},
|
|
157
|
+
occurred_at: new Date().toISOString(),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
run.snapshot = restoredSnapshot;
|
|
162
|
+
run.snapshot.state = restoredState;
|
|
163
|
+
run.status = restoredState;
|
|
164
|
+
run.lastOutput = restoredFromState
|
|
165
|
+
? `Local CLI restarted during ${restoredFromState}; provide guidance to continue this run.`
|
|
166
|
+
: String(restoredSnapshot.metadata?.last_agent_output || "");
|
|
167
|
+
run.startedAt = restoredSnapshot.created_at || null;
|
|
168
|
+
run.completedAt = restoredSnapshot.updated_at || null;
|
|
169
|
+
run.controlState = restoredState === "stopped" ? "stopped" : "waiting_human";
|
|
170
|
+
run.restoredFromState = restoredFromState;
|
|
171
|
+
return run;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function publishRestoredLocalCodingRun(run) {
|
|
175
|
+
if (!run?.restoredFromState) return run?.snapshot || null;
|
|
176
|
+
await updateRun(run, "waiting_human");
|
|
177
|
+
return run.snapshot;
|
|
178
|
+
}
|
|
179
|
+
|
|
74
180
|
export async function startLocalCodingRun(
|
|
75
181
|
run,
|
|
76
|
-
|
|
182
|
+
dependencies = {},
|
|
77
183
|
) {
|
|
78
|
-
if (run.child || TERMINAL_STATES.has(run.status) || run.startedAt) return;
|
|
184
|
+
if (run.operation || run.child || TERMINAL_STATES.has(run.status) || run.startedAt) return;
|
|
79
185
|
run.startedAt = new Date().toISOString();
|
|
186
|
+
run.controlState = "running";
|
|
187
|
+
return executeLocalCodingRun(run, dependencies);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function continueLocalCodingRun(run, guidance, dependencies = {}) {
|
|
191
|
+
const message = String(guidance || "").trim();
|
|
192
|
+
if (!message) throw new Error("Human guidance is required.");
|
|
193
|
+
if (run.operation || run.child) throw new Error("The selected coding run is still active.");
|
|
194
|
+
if (!RESUMABLE_STATES.has(run.status)) {
|
|
195
|
+
throw new Error("Human guidance is available only when the selected run is waiting.");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const nextAttempt = run.snapshot.attempts.length + 1;
|
|
199
|
+
if (nextAttempt > 5) throw new Error("The coding run reached the five-attempt safety limit.");
|
|
200
|
+
run.snapshot.charter.max_attempts = Math.max(run.snapshot.charter.max_attempts, nextAttempt);
|
|
201
|
+
run.completedAt = null;
|
|
202
|
+
run.stopRequested = false;
|
|
203
|
+
run.controlState = "running";
|
|
204
|
+
run.resumeCount += 1;
|
|
205
|
+
emitControl(run, "human_input", `Human guidance accepted: ${message}`);
|
|
206
|
+
return executeLocalCodingRun(run, dependencies, { humanGuidance: message, resume: true });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function continueLocalCodingRunAfterRepositoryApproval(
|
|
210
|
+
run,
|
|
211
|
+
{ repository, localPath } = {},
|
|
212
|
+
dependencies = {},
|
|
213
|
+
) {
|
|
214
|
+
const approvedRepository = String(repository || run.request.repository || "").trim();
|
|
215
|
+
const approvedPath = String(localPath || "").trim();
|
|
216
|
+
if (!approvedRepository || !approvedPath) {
|
|
217
|
+
throw new Error("An approved local repository and path are required.");
|
|
218
|
+
}
|
|
219
|
+
if (run.operation || run.child) throw new Error("The selected coding run is still active.");
|
|
220
|
+
if (run.status !== "waiting_human") {
|
|
221
|
+
throw new Error("Repository approval is available only while the run is waiting.");
|
|
222
|
+
}
|
|
223
|
+
if (run.snapshot.attempts.length > 0) {
|
|
224
|
+
throw new Error("Repository approval cannot restart a run after a coding attempt.");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
run.request.local_path = approvedPath;
|
|
228
|
+
run.snapshot.final_audit = null;
|
|
229
|
+
run.snapshot.events.push({
|
|
230
|
+
state: "queued",
|
|
231
|
+
message: "Local repository authorization accepted.",
|
|
232
|
+
detail: {
|
|
233
|
+
code: "local_repository_authorized",
|
|
234
|
+
repository: approvedRepository,
|
|
235
|
+
},
|
|
236
|
+
occurred_at: new Date().toISOString(),
|
|
237
|
+
});
|
|
238
|
+
run.snapshot.state = "queued";
|
|
239
|
+
run.status = "queued";
|
|
240
|
+
run.lastOutput = `Authorized local repository: ${approvedRepository}.`;
|
|
241
|
+
run.completedAt = null;
|
|
242
|
+
run.stopRequested = false;
|
|
243
|
+
run.controlState = "running";
|
|
244
|
+
emitControl(
|
|
245
|
+
run,
|
|
246
|
+
"repository_authorized",
|
|
247
|
+
`Authorized local repository selected: ${approvedRepository}.`,
|
|
248
|
+
);
|
|
249
|
+
return executeLocalCodingRun(run, dependencies, {
|
|
250
|
+
humanGuidance: `Repository scan approved: ${approvedRepository}.`,
|
|
251
|
+
resume: false,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function isLocalRepositoryApprovalPending(run) {
|
|
256
|
+
if (run?.status !== "waiting_human" || run.snapshot?.attempts?.length > 0) return false;
|
|
257
|
+
const events = Array.isArray(run.snapshot?.events) ? run.snapshot.events : [];
|
|
258
|
+
const markerIndex = events.findLastIndex(
|
|
259
|
+
(event) => event?.detail?.code === "local_repository_not_found",
|
|
260
|
+
);
|
|
261
|
+
if (markerIndex < 0) return false;
|
|
262
|
+
return events
|
|
263
|
+
.slice(markerIndex + 1)
|
|
264
|
+
.every((event) => event?.state === "waiting_human" || event?.state === "pr_ready_for_review");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function queueLocalCodingGuidance(run, guidance) {
|
|
268
|
+
const message = String(guidance || "").trim();
|
|
269
|
+
if (!message) throw new Error("Human guidance is required.");
|
|
270
|
+
run.pendingGuidance.push(message);
|
|
271
|
+
emitControl(
|
|
272
|
+
run,
|
|
273
|
+
"guidance_queued",
|
|
274
|
+
"Human guidance queued for the next executor handoff.",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function applyLocalHumanDecision(
|
|
279
|
+
run,
|
|
280
|
+
{ message, designApproval = false, requiredChecks = [], acceptanceCriteria = [] } = {},
|
|
281
|
+
dependencies = {},
|
|
282
|
+
) {
|
|
283
|
+
const guidance = String(message || "").trim();
|
|
284
|
+
if (!guidance) throw new Error("A human decision note is required.");
|
|
285
|
+
const charter = run.snapshot.charter;
|
|
286
|
+
if (acceptanceCriteria.length > 0) {
|
|
287
|
+
charter.acceptance_criteria = acceptanceCriteria.map(String).map((item) => item.trim()).filter(Boolean);
|
|
288
|
+
charter.acceptance_criteria_source = "explicit";
|
|
289
|
+
}
|
|
290
|
+
if (requiredChecks.length > 0) {
|
|
291
|
+
charter.required_checks = requiredChecks.map(String).map((item) => item.trim()).filter(Boolean);
|
|
292
|
+
}
|
|
293
|
+
if (designApproval) {
|
|
294
|
+
charter.design_review.summary = guidance;
|
|
295
|
+
charter.design_review.approved_by = run.session?.device_id || "local-cli-human";
|
|
296
|
+
charter.design_review.evidence_url = `local-cli://approval/${encodeURIComponent(run.id)}/${Date.now()}`;
|
|
297
|
+
charter.design_review.status = "approved";
|
|
298
|
+
}
|
|
299
|
+
return continueLocalCodingRun(run, guidance, dependencies);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function pauseLocalCodingRun(run) {
|
|
303
|
+
if (!run.child) throw new Error("The selected run has no active executor process.");
|
|
304
|
+
if (run.controlState === "paused") throw new Error("The executor process is already paused.");
|
|
305
|
+
if (process.platform === "win32") {
|
|
306
|
+
throw new Error("Process pause is not supported on Windows; stop the run instead.");
|
|
307
|
+
}
|
|
308
|
+
if (!run.child.kill("SIGSTOP")) throw new Error("The executor process could not be paused.");
|
|
309
|
+
run.controlState = "paused";
|
|
310
|
+
emitControl(run, "paused", "Executor paused by the local human operator.");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function resumeLocalCodingRun(run) {
|
|
314
|
+
if (!run.child) throw new Error("The selected run has no paused executor process.");
|
|
315
|
+
if (run.controlState !== "paused") throw new Error("The executor process is not paused.");
|
|
316
|
+
if (process.platform === "win32") {
|
|
317
|
+
throw new Error("Process resume is not supported on Windows.");
|
|
318
|
+
}
|
|
319
|
+
if (!run.child.kill("SIGCONT")) throw new Error("The executor process could not be resumed.");
|
|
320
|
+
run.controlState = "running";
|
|
321
|
+
emitControl(run, "resumed", "Executor resumed by the local human operator.");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function stopLocalCodingRun(run) {
|
|
325
|
+
if (run.status === "stopped") return run.snapshot;
|
|
326
|
+
if (run.stopRequested) {
|
|
327
|
+
await waitForLocalStop(run);
|
|
328
|
+
return run.snapshot;
|
|
329
|
+
}
|
|
330
|
+
run.stopRequested = true;
|
|
331
|
+
run.controlState = "stopping";
|
|
332
|
+
emitControl(run, "stopping", "Stop requested by the local human operator.");
|
|
333
|
+
|
|
334
|
+
const child = run.child;
|
|
335
|
+
if (!child) {
|
|
336
|
+
await finalizeLocalStop(run);
|
|
337
|
+
return run.snapshot;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let outcome = await signalAndWaitForClose(child, "SIGINT", 1_500);
|
|
341
|
+
if (outcome === "timeout" && run.child === child) {
|
|
342
|
+
outcome = await signalAndWaitForClose(child, "SIGTERM", 1_500);
|
|
343
|
+
}
|
|
344
|
+
if (outcome === "timeout" && run.child === child) {
|
|
345
|
+
await signalAndWaitForClose(child, "SIGKILL", 750);
|
|
346
|
+
}
|
|
347
|
+
await waitForLocalStop(run);
|
|
348
|
+
await finalizeLocalStop(run);
|
|
349
|
+
return run.snapshot;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function signalAndWaitForClose(child, signal, timeoutMs) {
|
|
353
|
+
const close = once(child, "close").then(() => "closed");
|
|
354
|
+
child.kill(signal);
|
|
355
|
+
return Promise.race([
|
|
356
|
+
close,
|
|
357
|
+
new Promise((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)),
|
|
358
|
+
]);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function waitForLocalStop(run) {
|
|
362
|
+
if (!run.operation) return;
|
|
363
|
+
await Promise.race([
|
|
364
|
+
run.operation.catch(() => null),
|
|
365
|
+
new Promise((resolve) => setTimeout(resolve, 1_500)),
|
|
366
|
+
]);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function finalizeLocalStop(run) {
|
|
370
|
+
if (run.status === "stopped") return;
|
|
371
|
+
if (run.snapshot.events.at(-1)?.state !== "stopped") {
|
|
372
|
+
run.snapshot.events.push({
|
|
373
|
+
state: "stopped",
|
|
374
|
+
message: "Coding loop stopped by the local human operator.",
|
|
375
|
+
detail: { source: "local_cli" },
|
|
376
|
+
occurred_at: new Date().toISOString(),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
await updateRun(run, "stopped");
|
|
380
|
+
}
|
|
80
381
|
|
|
382
|
+
async function executeLocalCodingRun(
|
|
383
|
+
run,
|
|
384
|
+
{ executeAttempt, executeIndependentAudit, collectEvidence } = {},
|
|
385
|
+
{ humanGuidance = "", resume = false } = {},
|
|
386
|
+
) {
|
|
387
|
+
const operation = runLocalCodingRun(run, {
|
|
388
|
+
executeAttempt,
|
|
389
|
+
executeIndependentAudit,
|
|
390
|
+
collectEvidence,
|
|
391
|
+
humanGuidance,
|
|
392
|
+
resume,
|
|
393
|
+
});
|
|
394
|
+
run.operation = operation;
|
|
395
|
+
try {
|
|
396
|
+
return await operation;
|
|
397
|
+
} finally {
|
|
398
|
+
if (run.operation === operation) run.operation = null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function runLocalCodingRun(
|
|
403
|
+
run,
|
|
404
|
+
{ executeAttempt, executeIndependentAudit, collectEvidence, humanGuidance, resume },
|
|
405
|
+
) {
|
|
81
406
|
let repositoryPath;
|
|
82
407
|
try {
|
|
83
408
|
repositoryPath = await resolveRepositoryPath(run.request);
|
|
@@ -91,10 +416,13 @@ export async function startLocalCodingRun(
|
|
|
91
416
|
detail: { code: "local_repository_not_found" },
|
|
92
417
|
occurred_at: new Date().toISOString(),
|
|
93
418
|
});
|
|
419
|
+
await cleanupLocalAttachments(run);
|
|
94
420
|
await updateRun(run, "waiting_human");
|
|
95
421
|
return;
|
|
96
422
|
}
|
|
97
423
|
|
|
424
|
+
markAttachmentsDelivered(run.request.attachments);
|
|
425
|
+
|
|
98
426
|
const executor =
|
|
99
427
|
executeAttempt ||
|
|
100
428
|
((context) =>
|
|
@@ -127,6 +455,10 @@ export async function startLocalCodingRun(
|
|
|
127
455
|
executeAttempt: executor,
|
|
128
456
|
executeIndependentAudit: independentAuditor,
|
|
129
457
|
collectEvidence: evidenceCollector,
|
|
458
|
+
humanGuidance,
|
|
459
|
+
resume,
|
|
460
|
+
shouldStop: () => run.stopRequested,
|
|
461
|
+
consumeHumanGuidance: () => run.pendingGuidance.splice(0).join("\n\n"),
|
|
130
462
|
onUpdate: async (snapshot) => {
|
|
131
463
|
run.snapshot = snapshot;
|
|
132
464
|
await updateRun(run, snapshot.state);
|
|
@@ -142,6 +474,31 @@ export async function startLocalCodingRun(
|
|
|
142
474
|
occurred_at: new Date().toISOString(),
|
|
143
475
|
});
|
|
144
476
|
await updateRun(run, "failed");
|
|
477
|
+
} finally {
|
|
478
|
+
const cleanupError = await cleanupLocalAttachments(run);
|
|
479
|
+
if (cleanupError) {
|
|
480
|
+
const message = `Local attachment cleanup failed: ${cleanupError}`;
|
|
481
|
+
emitOutput(run, message);
|
|
482
|
+
run.snapshot.events.push({
|
|
483
|
+
state: "failed",
|
|
484
|
+
message: "Local attachment cleanup failed.",
|
|
485
|
+
detail: { error: cleanupError },
|
|
486
|
+
occurred_at: new Date().toISOString(),
|
|
487
|
+
});
|
|
488
|
+
run.snapshot.state = "failed";
|
|
489
|
+
}
|
|
490
|
+
await updateRun(run, run.snapshot.state);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function cleanupLocalAttachments(run) {
|
|
495
|
+
try {
|
|
496
|
+
await cleanupRunAttachments(run.request.attachments);
|
|
497
|
+
markAttachmentsCleaned(run.request.attachments);
|
|
498
|
+
return null;
|
|
499
|
+
} catch (error) {
|
|
500
|
+
markAttachmentsCleanupFailed(run.request.attachments, error);
|
|
501
|
+
return error instanceof Error ? error.message : "attachment cleanup failed";
|
|
145
502
|
}
|
|
146
503
|
}
|
|
147
504
|
|
|
@@ -174,11 +531,14 @@ async function executeProcessAttempt({
|
|
|
174
531
|
repositoryPath,
|
|
175
532
|
run,
|
|
176
533
|
marker = "SIMY_RESULT_JSON:",
|
|
534
|
+
attemptNumber = null,
|
|
177
535
|
}) {
|
|
178
|
-
const command = resolveBackendCommand({ backend, instruction, repositoryPath });
|
|
536
|
+
const command = await resolveBackendCommand({ backend, instruction, repositoryPath });
|
|
179
537
|
const startedAt = new Date().toISOString();
|
|
180
538
|
const stdout = [];
|
|
181
539
|
const stderr = [];
|
|
540
|
+
const tokenUsageRecords = [];
|
|
541
|
+
const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
|
|
182
542
|
|
|
183
543
|
return new Promise((resolve) => {
|
|
184
544
|
let settled = false;
|
|
@@ -189,17 +549,36 @@ async function executeProcessAttempt({
|
|
|
189
549
|
});
|
|
190
550
|
run.child = child;
|
|
191
551
|
|
|
192
|
-
const
|
|
552
|
+
const stdoutDecoder = createProviderStreamDecoder({
|
|
553
|
+
backend,
|
|
554
|
+
stream: "stdout",
|
|
555
|
+
onLine: (line) => emitOutput(run, line, { backend }),
|
|
556
|
+
onUsage: (usage) =>
|
|
557
|
+
tokenUsageRecords.push({
|
|
558
|
+
...usage,
|
|
559
|
+
phase,
|
|
560
|
+
...(Number.isInteger(attemptNumber) ? { attempt_number: attemptNumber } : {}),
|
|
561
|
+
backend,
|
|
562
|
+
}),
|
|
563
|
+
});
|
|
564
|
+
const stderrDecoder = createProviderStreamDecoder({
|
|
565
|
+
backend,
|
|
566
|
+
stream: "stderr",
|
|
567
|
+
onLine: (line) => emitOutput(run, line, { backend }),
|
|
568
|
+
});
|
|
569
|
+
const collect = (target, decoder, chunk) => {
|
|
193
570
|
const text = chunk.toString("utf8");
|
|
194
571
|
target.push(text);
|
|
195
|
-
|
|
572
|
+
decoder.push(text);
|
|
196
573
|
};
|
|
197
|
-
child.stdout?.on("data", (chunk) => collect(stdout, chunk));
|
|
198
|
-
child.stderr?.on("data", (chunk) => collect(stderr, chunk));
|
|
574
|
+
child.stdout?.on("data", (chunk) => collect(stdout, stdoutDecoder, chunk));
|
|
575
|
+
child.stderr?.on("data", (chunk) => collect(stderr, stderrDecoder, chunk));
|
|
199
576
|
|
|
200
577
|
const finish = ({ exitCode = null, error = null } = {}) => {
|
|
201
578
|
if (settled) return;
|
|
202
579
|
settled = true;
|
|
580
|
+
stdoutDecoder.flush();
|
|
581
|
+
stderrDecoder.flush();
|
|
203
582
|
run.child = null;
|
|
204
583
|
const rawStdout = stdout.join("");
|
|
205
584
|
const assistantText = extractAssistantText(rawStdout);
|
|
@@ -212,6 +591,8 @@ async function executeProcessAttempt({
|
|
|
212
591
|
exitCode,
|
|
213
592
|
error,
|
|
214
593
|
result: Object.keys(result).length > 0 ? result : parse(rawStdout),
|
|
594
|
+
tokenUsage:
|
|
595
|
+
tokenUsageRecords.length > 0 ? { records: tokenUsageRecords } : {},
|
|
215
596
|
logs: [...stdout, ...stderr]
|
|
216
597
|
.join("")
|
|
217
598
|
.split(/\r?\n/)
|
|
@@ -227,13 +608,14 @@ async function executeProcessAttempt({
|
|
|
227
608
|
});
|
|
228
609
|
}
|
|
229
610
|
|
|
230
|
-
function resolveBackendCommand({ backend, instruction, repositoryPath }) {
|
|
611
|
+
async function resolveBackendCommand({ backend, instruction, repositoryPath }) {
|
|
231
612
|
if (backend === "claude") {
|
|
232
613
|
const override = process.env.SIMY_CLAUDE_COMMAND;
|
|
233
614
|
if (override) return shellCommand(override, repositoryPath, instruction);
|
|
615
|
+
const binary = (await resolveBackendExecutable("claude")) || "claude";
|
|
234
616
|
return {
|
|
235
|
-
bin:
|
|
236
|
-
args:
|
|
617
|
+
bin: binary,
|
|
618
|
+
args: claudeBackendArgs(instruction),
|
|
237
619
|
env: {},
|
|
238
620
|
spawn,
|
|
239
621
|
};
|
|
@@ -241,14 +623,29 @@ function resolveBackendCommand({ backend, instruction, repositoryPath }) {
|
|
|
241
623
|
|
|
242
624
|
const override = process.env.SIMY_CODEX_COMMAND;
|
|
243
625
|
if (override) return shellCommand(override, repositoryPath, instruction);
|
|
626
|
+
const binary = (await resolveBackendExecutable("codex")) || "codex";
|
|
244
627
|
return {
|
|
245
|
-
bin:
|
|
628
|
+
bin: binary,
|
|
246
629
|
args: ["exec", "--json", instruction],
|
|
247
630
|
env: {},
|
|
248
631
|
spawn,
|
|
249
632
|
};
|
|
250
633
|
}
|
|
251
634
|
|
|
635
|
+
export function claudeBackendArgs(instruction) {
|
|
636
|
+
return [
|
|
637
|
+
"-p",
|
|
638
|
+
instruction,
|
|
639
|
+
"--output-format",
|
|
640
|
+
"stream-json",
|
|
641
|
+
"--verbose",
|
|
642
|
+
// The Coding Loop is already scoped to a verified checkout and explicitly
|
|
643
|
+
// approved by the user. Print mode cannot display permission prompts, so
|
|
644
|
+
// edits would otherwise be silently unavailable to the executor.
|
|
645
|
+
"--dangerously-skip-permissions",
|
|
646
|
+
];
|
|
647
|
+
}
|
|
648
|
+
|
|
252
649
|
function shellCommand(command, cwd, instruction) {
|
|
253
650
|
return {
|
|
254
651
|
bin: process.platform === "win32" ? "cmd.exe" : "sh",
|
|
@@ -259,12 +656,15 @@ function shellCommand(command, cwd, instruction) {
|
|
|
259
656
|
};
|
|
260
657
|
}
|
|
261
658
|
|
|
262
|
-
async function resolveRepositoryPath(request) {
|
|
659
|
+
export async function resolveRepositoryPath(request) {
|
|
263
660
|
const repository = String(request.repository || "").replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
|
|
264
661
|
const repoName = repository.split("/").filter(Boolean).at(-1);
|
|
265
662
|
if (!repoName) throw new Error("The coding loop did not specify a valid GitHub repository.");
|
|
266
663
|
|
|
267
|
-
|
|
664
|
+
// An operator-provided root scopes this CLI process to the intended workspace.
|
|
665
|
+
// Persisted inventory paths can outlive a checkout, so only prefer them when no
|
|
666
|
+
// explicit process scope is configured.
|
|
667
|
+
const roots = [process.env.SIMY_REPO_ROOT, request.local_path, process.cwd()].filter(Boolean);
|
|
268
668
|
const candidates = new Set();
|
|
269
669
|
for (const root of roots) {
|
|
270
670
|
const resolved = path.resolve(String(root));
|
|
@@ -280,29 +680,19 @@ async function resolveRepositoryPath(request) {
|
|
|
280
680
|
|
|
281
681
|
throw new Error(
|
|
282
682
|
`Repository ${repository} was not found below ${process.env.SIMY_REPO_ROOT || process.cwd()}. ` +
|
|
283
|
-
"
|
|
683
|
+
"Open SIMY CLI and press s to authorize a local repository scan, or set SIMY_REPO_ROOT.",
|
|
284
684
|
);
|
|
285
685
|
}
|
|
286
686
|
|
|
287
687
|
async function gitRemote(cwd) {
|
|
288
688
|
try {
|
|
289
689
|
const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], { cwd });
|
|
290
|
-
return normalizeGitHubRemote(stdout);
|
|
690
|
+
return normalizeGitHubRemote(stdout)?.toLowerCase() ?? null;
|
|
291
691
|
} catch {
|
|
292
692
|
return null;
|
|
293
693
|
}
|
|
294
694
|
}
|
|
295
695
|
|
|
296
|
-
function normalizeGitHubRemote(value) {
|
|
297
|
-
return String(value || "")
|
|
298
|
-
.trim()
|
|
299
|
-
.replace(/^git@github\.com:/, "")
|
|
300
|
-
.replace(/^ssh:\/\/git@github\.com\//, "")
|
|
301
|
-
.replace(/^https?:\/\/github\.com\//, "")
|
|
302
|
-
.replace(/\.git$/, "")
|
|
303
|
-
.toLowerCase();
|
|
304
|
-
}
|
|
305
|
-
|
|
306
696
|
async function pathExists(value) {
|
|
307
697
|
try {
|
|
308
698
|
await access(value);
|
|
@@ -332,25 +722,55 @@ function extractAssistantText(raw) {
|
|
|
332
722
|
return messages.join("\n");
|
|
333
723
|
}
|
|
334
724
|
|
|
335
|
-
function emitOutput(run, text) {
|
|
725
|
+
function emitOutput(run, text, { backend = run.request.backend } = {}) {
|
|
336
726
|
for (const line of String(text).split(/\r?\n/)) {
|
|
337
727
|
if (!line.trim()) continue;
|
|
728
|
+
const occurredAt = new Date().toISOString();
|
|
338
729
|
run.lastOutput = line;
|
|
730
|
+
run.logs.push({
|
|
731
|
+
text: line,
|
|
732
|
+
backend,
|
|
733
|
+
occurred_at: occurredAt,
|
|
734
|
+
});
|
|
735
|
+
if (run.logs.length > MAX_LOCAL_LOG_LINES) {
|
|
736
|
+
run.logs.splice(0, run.logs.length - MAX_LOCAL_LOG_LINES);
|
|
737
|
+
}
|
|
339
738
|
run.emitter.emit("event", {
|
|
340
739
|
type: "output",
|
|
341
740
|
run_id: run.id,
|
|
342
|
-
backend
|
|
741
|
+
backend,
|
|
343
742
|
text: line,
|
|
344
|
-
occurred_at:
|
|
743
|
+
occurred_at: occurredAt,
|
|
345
744
|
});
|
|
346
745
|
}
|
|
347
746
|
}
|
|
348
747
|
|
|
748
|
+
function emitControl(run, state, message) {
|
|
749
|
+
run.emitter.emit("event", {
|
|
750
|
+
type: "control",
|
|
751
|
+
run_id: run.id,
|
|
752
|
+
state,
|
|
753
|
+
message,
|
|
754
|
+
occurred_at: new Date().toISOString(),
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
349
758
|
async function updateRun(run, state) {
|
|
350
759
|
run.status = state;
|
|
351
760
|
run.snapshot.state = state;
|
|
352
761
|
run.snapshot.updated_at = new Date().toISOString();
|
|
353
762
|
run.completedAt = TERMINAL_STATES.has(state) ? run.snapshot.updated_at : null;
|
|
763
|
+
if (state === "waiting_human" || state === "pr_ready_for_review") {
|
|
764
|
+
run.controlState = "waiting_human";
|
|
765
|
+
} else if (state === "stopped") {
|
|
766
|
+
run.controlState = "stopped";
|
|
767
|
+
} else if (state === "merge_ready") {
|
|
768
|
+
run.controlState = "complete";
|
|
769
|
+
} else if (TERMINAL_STATES.has(state)) {
|
|
770
|
+
run.controlState = "complete";
|
|
771
|
+
} else if (run.controlState !== "paused" && run.controlState !== "stopping") {
|
|
772
|
+
run.controlState = "running";
|
|
773
|
+
}
|
|
354
774
|
|
|
355
775
|
run.emitter.emit("event", { type: "run", run: toLedgerSnapshot(run.snapshot) });
|
|
356
776
|
const latestEvent = run.snapshot.events.at(-1);
|
|
@@ -395,15 +815,18 @@ async function drainLedgerUpdates(run) {
|
|
|
395
815
|
}
|
|
396
816
|
|
|
397
817
|
async function persistRunStatus(run, update) {
|
|
398
|
-
const url =
|
|
818
|
+
const url = webApiUrl(`runs/${encodeURIComponent(run.id)}/status`, {
|
|
819
|
+
webOrigin: run.apiOrigin,
|
|
820
|
+
apiBaseUrl: run.session.api_base_url,
|
|
821
|
+
});
|
|
399
822
|
try {
|
|
400
823
|
const response = await fetch(url, {
|
|
401
824
|
method: "POST",
|
|
402
825
|
signal: AbortSignal.timeout(5000),
|
|
403
|
-
headers:
|
|
404
|
-
|
|
405
|
-
"Content-Type": "application/json",
|
|
406
|
-
|
|
826
|
+
headers: webApiHeaders(
|
|
827
|
+
{ token: run.session.token },
|
|
828
|
+
{ "Content-Type": "application/json" },
|
|
829
|
+
),
|
|
407
830
|
body: JSON.stringify({
|
|
408
831
|
state: update.state,
|
|
409
832
|
last_agent_output: redactExecutionText(run.lastOutput, { maxChars: 2_000 }).text || null,
|
|
@@ -420,15 +843,32 @@ async function persistRunStatus(run, update) {
|
|
|
420
843
|
}
|
|
421
844
|
|
|
422
845
|
export function toLedgerSnapshot(snapshot) {
|
|
846
|
+
const attachments = snapshot.charter.attachments || [];
|
|
423
847
|
return {
|
|
424
848
|
...snapshot,
|
|
849
|
+
charter: {
|
|
850
|
+
...snapshot.charter,
|
|
851
|
+
attachments: (snapshot.charter.attachments || []).map(attachmentDescriptorForLedger),
|
|
852
|
+
},
|
|
425
853
|
state: toCompatibleCodingLoopState(snapshot.state),
|
|
426
|
-
attempts: snapshot.attempts.map(redactedAttemptForLedger),
|
|
427
|
-
events: snapshot.events.map((item) =>
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
854
|
+
attempts: snapshot.attempts.map((attempt) => redactedAttemptForLedger(attempt, attachments)),
|
|
855
|
+
events: snapshot.events.map((item) => {
|
|
856
|
+
const presentation = summarizeCodingLoopEvent(snapshot, item);
|
|
857
|
+
return {
|
|
858
|
+
...item,
|
|
859
|
+
state: toCompatibleCodingLoopState(item.state),
|
|
860
|
+
detail: redactedEventDetail(
|
|
861
|
+
{
|
|
862
|
+
...item.detail,
|
|
863
|
+
step_summary: presentation.summary,
|
|
864
|
+
step_result: presentation.result,
|
|
865
|
+
next_action: presentation.next,
|
|
866
|
+
},
|
|
867
|
+
item.state,
|
|
868
|
+
attachments,
|
|
869
|
+
),
|
|
870
|
+
};
|
|
871
|
+
}),
|
|
432
872
|
metadata: {
|
|
433
873
|
...(snapshot.metadata || {}),
|
|
434
874
|
pr_lifecycle_state: snapshot.state,
|
|
@@ -440,15 +880,19 @@ export function toLedgerSnapshot(snapshot) {
|
|
|
440
880
|
};
|
|
441
881
|
}
|
|
442
882
|
|
|
443
|
-
function redactedAttemptForLedger(attempt) {
|
|
883
|
+
function redactedAttemptForLedger(attempt, attachments) {
|
|
444
884
|
const instruction = redactExecutionText(attempt.instruction);
|
|
445
885
|
const executorLogs = redactExecutionLogs(attempt.executor_logs);
|
|
446
|
-
const
|
|
886
|
+
const redactedInstruction = redactAttachmentPaths(instruction.text, attachments);
|
|
887
|
+
const redactedExecutorLogs = executorLogs.lines.map((line) =>
|
|
888
|
+
redactAttachmentPaths(line, attachments),
|
|
889
|
+
);
|
|
890
|
+
const logText = redactedExecutorLogs.join("\n");
|
|
447
891
|
return {
|
|
448
892
|
...attempt,
|
|
449
|
-
instruction:
|
|
450
|
-
instruction_sha256: executionTextSha256(
|
|
451
|
-
executor_logs:
|
|
893
|
+
instruction: redactedInstruction,
|
|
894
|
+
instruction_sha256: executionTextSha256(redactedInstruction),
|
|
895
|
+
executor_logs: redactedExecutorLogs,
|
|
452
896
|
executor_logs_sha256: executionTextSha256(logText),
|
|
453
897
|
io_redaction: {
|
|
454
898
|
policy_version: EXECUTION_IO_POLICY_VERSION,
|
|
@@ -462,15 +906,20 @@ function redactedAttemptForLedger(attempt) {
|
|
|
462
906
|
};
|
|
463
907
|
}
|
|
464
908
|
|
|
465
|
-
function redactedEventDetail(value, lifecycleState) {
|
|
909
|
+
function redactedEventDetail(value, lifecycleState, attachments) {
|
|
466
910
|
const detail = value && typeof value === "object" ? value : {};
|
|
467
911
|
const result = {};
|
|
468
912
|
for (const [key, item] of Object.entries(detail)) {
|
|
469
913
|
if (["instruction", "reinstruction", "prompt", "last_agent_output"].includes(key)) {
|
|
470
|
-
result[key] =
|
|
914
|
+
result[key] = redactAttachmentPaths(
|
|
915
|
+
redactExecutionText(item, { maxChars: 24_000 }).text,
|
|
916
|
+
attachments,
|
|
917
|
+
);
|
|
471
918
|
} else if (["executor_logs", "stdout", "stderr"].includes(key)) {
|
|
472
919
|
const logs = Array.isArray(item) ? item : [item];
|
|
473
|
-
result[key] = redactExecutionLogs(logs).lines
|
|
920
|
+
result[key] = redactExecutionLogs(logs).lines.map((line) =>
|
|
921
|
+
redactAttachmentPaths(line, attachments),
|
|
922
|
+
);
|
|
474
923
|
} else {
|
|
475
924
|
result[key] = item;
|
|
476
925
|
}
|
|
@@ -479,6 +928,16 @@ function redactedEventDetail(value, lifecycleState) {
|
|
|
479
928
|
return result;
|
|
480
929
|
}
|
|
481
930
|
|
|
931
|
+
function redactAttachmentPaths(value, attachments) {
|
|
932
|
+
let text = String(value || "");
|
|
933
|
+
for (const attachment of attachments) {
|
|
934
|
+
if (typeof attachment?.local_path === "string" && attachment.local_path) {
|
|
935
|
+
text = text.replaceAll(attachment.local_path, `[LOCAL_ATTACHMENT:${attachment.name}]`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return text;
|
|
939
|
+
}
|
|
940
|
+
|
|
482
941
|
function observedEvidenceForLedger(value) {
|
|
483
942
|
if (!value || typeof value !== "object") return null;
|
|
484
943
|
const local = value.local && typeof value.local === "object" ? value.local : {};
|
|
@@ -530,6 +989,8 @@ export function toCompatibleCodingLoopState(state) {
|
|
|
530
989
|
return "waiting_human";
|
|
531
990
|
case "merge_ready":
|
|
532
991
|
return "done";
|
|
992
|
+
case "stopped":
|
|
993
|
+
return "failed";
|
|
533
994
|
default:
|
|
534
995
|
return state;
|
|
535
996
|
}
|