@tea-agent/loop-agent 0.16.25 → 0.17.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/CHANGELOG.md +46 -0
- package/README.md +14 -3
- package/dist/cli/command-definitions.js +43 -0
- package/dist/cli/program.js +26 -0
- package/dist/commands/dag-approve.js +4 -0
- package/dist/commands/dag-resume.js +1 -0
- package/dist/commands/dag-validate.js +6 -0
- package/dist/commands/operator.js +44 -0
- package/dist/commands/task-contract.js +271 -0
- package/dist/executors/dag-pi-executor.js +118 -16
- package/dist/executors/pi-executor.js +206 -13
- package/dist/executors/pi-sdk-executor.js +21 -6
- package/dist/executors/shell-executor.js +85 -8
- package/dist/executors/shell-presets.js +16 -3
- package/dist/executors/shell-write-guard.js +64 -2
- package/dist/shared/operator/capabilities.js +255 -0
- package/dist/shared/operator/envelope.js +59 -0
- package/dist/shared/operator/index.js +4 -0
- package/dist/shared/operator/registry.js +38 -0
- package/dist/shared/operator/types.js +5 -0
- package/dist/task/contract/adopt.js +166 -0
- package/dist/task/contract/apply.js +326 -0
- package/dist/task/contract/canonicalize.js +60 -0
- package/dist/task/contract/constants.js +29 -0
- package/dist/task/contract/diff.js +177 -0
- package/dist/task/contract/hash.js +42 -0
- package/dist/task/contract/import-revision.js +96 -0
- package/dist/task/contract/index.js +17 -0
- package/dist/task/contract/journal.js +155 -0
- package/dist/task/contract/lock.js +153 -0
- package/dist/task/contract/observe.js +296 -0
- package/dist/task/contract/paths.js +19 -0
- package/dist/task/contract/project.js +170 -0
- package/dist/task/contract/recover.js +312 -0
- package/dist/task/contract/request-ledger.js +37 -0
- package/dist/task/contract/schema.js +151 -0
- package/dist/task/contract/transaction.js +160 -0
- package/dist/task/contract/types.js +1 -0
- package/dist/task/contract/validate-draft.js +106 -0
- package/dist/task/index.js +3 -0
- package/dist/task/operator/capabilities.js +6 -0
- package/dist/task/operator/envelope.js +2 -0
- package/dist/task/operator/index.js +5 -0
- package/dist/task/operator/registry.js +2 -0
- package/dist/task/operator/types.js +1 -0
- package/dist/task/runtime.js +5 -1
- package/dist/task/source-references.js +7 -0
- package/dist/worker/cli.js +150 -32
- package/dist/worker/console/app-data.js +185 -0
- package/dist/worker/console/dag-confirmation.js +313 -0
- package/dist/worker/console/doctor.js +169 -0
- package/dist/worker/console/draft-store.js +80 -0
- package/dist/worker/console/index.js +15 -0
- package/dist/worker/console/interview/assessment.js +67 -0
- package/dist/worker/console/interview/session.js +100 -0
- package/dist/worker/console/interview/tools.js +109 -0
- package/dist/worker/console/loopback.js +16 -0
- package/dist/worker/console/observe-health-match.js +174 -0
- package/dist/worker/console/observe-link.js +33 -0
- package/dist/worker/console/operation-runner.js +166 -0
- package/dist/worker/console/operation-sse.js +158 -0
- package/dist/worker/console/operation-store.js +147 -0
- package/dist/worker/console/operator-actions.js +769 -0
- package/dist/worker/console/pi-readiness.js +94 -0
- package/dist/worker/console/recovery-cta.js +133 -0
- package/dist/worker/console/repo-fingerprint.js +29 -0
- package/dist/worker/console/resource-loader.js +95 -0
- package/dist/worker/console/routes.js +368 -0
- package/dist/worker/console/security.js +126 -0
- package/dist/worker/console/server.js +149 -0
- package/dist/worker/console/sibling-controller.js +28 -0
- package/dist/worker/console/static/assets/index-CbnMgdWa.js +9 -0
- package/dist/worker/console/static/assets/index-Dnj0RVs8.css +1 -0
- package/dist/worker/console/static/index.html +13 -0
- package/dist/worker/console/vite.config.js +27 -0
- package/dist/worker/delivery/git-transaction.js +43 -8
- package/dist/worker/observe/health.js +57 -0
- package/dist/worker/observe/paths.js +81 -0
- package/dist/worker/observe/routes.js +142 -27
- package/dist/worker/observe/spec-evidence.js +84 -0
- package/dist/worker/observe/static/api.js +23 -0
- package/dist/worker/observe/static/state.js +26 -0
- package/dist/worker/observe/static/styles.css +10 -0
- package/dist/worker/observe/static/views/dag-inspector.js +173 -6
- package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
- package/dist/workflows/dag/frontend-repair.js +1 -10
- package/dist/workflows/dag/init-hybrid.js +372 -115
- package/dist/workflows/dag/node-execution.js +57 -6
- package/dist/workflows/dag/project-governance-context.js +508 -0
- package/dist/workflows/dag/prompt.js +46 -1
- package/dist/workflows/dag/retry-policy.js +16 -1
- package/dist/workflows/dag/runner.js +9 -0
- package/dist/workflows/dag/skill-snapshot.js +1 -0
- package/dist/workflows/dag/task-contract-binding.js +138 -0
- package/dist/workflows/dag/types.js +84 -10
- package/dist/workflows/dag/validate.js +53 -7
- package/docs/README.md +2 -0
- package/docs/architecture/evolution.md +2 -0
- package/docs/architecture/system-overview.md +6 -0
- package/docs/architecture/worker-and-feature.md +7 -0
- package/docs/templates/agent-dag.schema.json +64 -2
- package/docs/templates/agent-dag.supervised-implementation.json +1 -0
- package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
- package/docs/templates/backend-test-dag.json +26 -154
- package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
- package/package.json +8 -2
- package/skills/agent-worker/SKILL.md +1 -0
- package/skills/agent-worker/references/agent-worker-operator.md +3 -2
- package/skills/frontend-design-review/SKILL.md +25 -16
- package/skills/frontend-implementation/references/node-contracts.md +5 -5
- package/skills/loop-agent/references/command-reference.md +48 -1
- package/skills/loop-agent/references/hybrid-dag.md +4 -4
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readJsonIfExists, sha256Utf8, writeSecureJson, } from "./app-data.js";
|
|
4
|
+
export const ALL_CONFIRMATION_CHALLENGES = [
|
|
5
|
+
"source",
|
|
6
|
+
"writer-scope",
|
|
7
|
+
"verification",
|
|
8
|
+
"human-gates",
|
|
9
|
+
];
|
|
10
|
+
export const DEFAULT_CONFIRMATION_TTL_MS = 30 * 60 * 1000;
|
|
11
|
+
function confirmationPath(appData, confirmationId) {
|
|
12
|
+
return path.join(appData.confirmations, `${confirmationId}.json`);
|
|
13
|
+
}
|
|
14
|
+
export function newConfirmationId() {
|
|
15
|
+
return `conf_${randomBytes(12).toString("hex")}`;
|
|
16
|
+
}
|
|
17
|
+
export function hashDagBytes(bytes) {
|
|
18
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
19
|
+
}
|
|
20
|
+
export function resolveRequiredCurrentBindings(current) {
|
|
21
|
+
if (!current) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
message: "server-derived current bindings are required (dagSha256, sourceBindingSha256, taskContractBinding, controllerFingerprint, validationResultSha256)",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const dagSha256 = typeof current.dagSha256 === "string" ? current.dagSha256.trim() : "";
|
|
28
|
+
const sourceBindingSha256 = typeof current.sourceBindingSha256 === "string"
|
|
29
|
+
? current.sourceBindingSha256.trim()
|
|
30
|
+
: "";
|
|
31
|
+
const controllerFingerprint = typeof current.controllerFingerprint === "string"
|
|
32
|
+
? current.controllerFingerprint.trim()
|
|
33
|
+
: "";
|
|
34
|
+
const validationResultSha256 = typeof current.validationResultSha256 === "string"
|
|
35
|
+
? current.validationResultSha256.trim()
|
|
36
|
+
: "";
|
|
37
|
+
const taskContractBinding = current.taskContractBinding;
|
|
38
|
+
const missing = [];
|
|
39
|
+
if (!dagSha256)
|
|
40
|
+
missing.push("dagSha256");
|
|
41
|
+
if (!sourceBindingSha256)
|
|
42
|
+
missing.push("sourceBindingSha256");
|
|
43
|
+
if (!controllerFingerprint)
|
|
44
|
+
missing.push("controllerFingerprint");
|
|
45
|
+
if (!validationResultSha256)
|
|
46
|
+
missing.push("validationResultSha256");
|
|
47
|
+
if (!taskContractBinding ||
|
|
48
|
+
typeof taskContractBinding !== "object" ||
|
|
49
|
+
typeof taskContractBinding.taskId !== "string" ||
|
|
50
|
+
!taskContractBinding.taskId.trim()) {
|
|
51
|
+
missing.push("taskContractBinding");
|
|
52
|
+
}
|
|
53
|
+
if (missing.length > 0) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
message: `missing server-derived current bindings: ${missing.join(", ")}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
value: {
|
|
62
|
+
dagSha256,
|
|
63
|
+
sourceBindingSha256,
|
|
64
|
+
controllerFingerprint,
|
|
65
|
+
validationResultSha256,
|
|
66
|
+
taskContractBinding: taskContractBinding,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function detectBindingDrift(rec, current) {
|
|
71
|
+
if (current.dagSha256 !== rec.dagSha256) {
|
|
72
|
+
return "dagSha256 binding drift";
|
|
73
|
+
}
|
|
74
|
+
if (current.sourceBindingSha256 !== rec.sourceBindingSha256) {
|
|
75
|
+
return "sourceBindingSha256 binding drift";
|
|
76
|
+
}
|
|
77
|
+
if (current.controllerFingerprint !== rec.controllerFingerprint) {
|
|
78
|
+
return "controllerFingerprint binding drift";
|
|
79
|
+
}
|
|
80
|
+
if (current.validationResultSha256 !== rec.validationResultSha256) {
|
|
81
|
+
return "validationResultSha256 binding drift";
|
|
82
|
+
}
|
|
83
|
+
const a = sha256Utf8(JSON.stringify(rec.taskContractBinding));
|
|
84
|
+
const b = sha256Utf8(JSON.stringify(current.taskContractBinding));
|
|
85
|
+
if (a !== b)
|
|
86
|
+
return "taskContractBinding drift";
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
export class DagConfirmationStore {
|
|
90
|
+
appData;
|
|
91
|
+
constructor(appData) {
|
|
92
|
+
this.appData = appData;
|
|
93
|
+
}
|
|
94
|
+
async get(confirmationId) {
|
|
95
|
+
const rec = await readJsonIfExists(confirmationPath(this.appData, confirmationId));
|
|
96
|
+
if (!rec)
|
|
97
|
+
return undefined;
|
|
98
|
+
return this.materializeExpiry(rec);
|
|
99
|
+
}
|
|
100
|
+
async materializeExpiry(rec, now = Date.now()) {
|
|
101
|
+
if ((rec.state === "prepared" || rec.state === "confirmed") &&
|
|
102
|
+
Date.parse(rec.expiresAt) <= now) {
|
|
103
|
+
const expired = {
|
|
104
|
+
...rec,
|
|
105
|
+
state: "expired",
|
|
106
|
+
staleReason: rec.staleReason ?? "ttl exceeded (30min default)",
|
|
107
|
+
};
|
|
108
|
+
await writeSecureJson(confirmationPath(this.appData, rec.confirmationId), expired);
|
|
109
|
+
return expired;
|
|
110
|
+
}
|
|
111
|
+
return rec;
|
|
112
|
+
}
|
|
113
|
+
async prepare(input) {
|
|
114
|
+
const now = input.now ?? new Date();
|
|
115
|
+
const ttl = input.ttlMs ?? DEFAULT_CONFIRMATION_TTL_MS;
|
|
116
|
+
const confirmationId = newConfirmationId();
|
|
117
|
+
const rec = {
|
|
118
|
+
schemaVersion: 1,
|
|
119
|
+
confirmationId,
|
|
120
|
+
state: "prepared",
|
|
121
|
+
preparedAt: now.toISOString(),
|
|
122
|
+
expiresAt: new Date(now.getTime() + ttl).toISOString(),
|
|
123
|
+
operatorSessionId: input.operatorSessionId,
|
|
124
|
+
dagHandle: input.dagHandle,
|
|
125
|
+
dagSha256: hashDagBytes(input.dagBytes),
|
|
126
|
+
dagBytesPath: input.dagBytesPath,
|
|
127
|
+
sourceBindingSha256: input.sourceBindingSha256,
|
|
128
|
+
taskContractBinding: input.taskContractBinding,
|
|
129
|
+
controllerFingerprint: input.controllerFingerprint,
|
|
130
|
+
validationResultSha256: input.validationResultSha256,
|
|
131
|
+
confirmedWriterScopes: input.confirmedWriterScopes ?? [],
|
|
132
|
+
confirmedChallenges: [],
|
|
133
|
+
reviewPacket: input.reviewPacket,
|
|
134
|
+
};
|
|
135
|
+
await writeSecureJson(confirmationPath(this.appData, confirmationId), rec);
|
|
136
|
+
return rec;
|
|
137
|
+
}
|
|
138
|
+
async confirm(input) {
|
|
139
|
+
const resolved = resolveRequiredCurrentBindings(input.current);
|
|
140
|
+
if (!resolved.ok) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
code: "INVALID_INPUT",
|
|
144
|
+
message: resolved.message,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const current = resolved.value;
|
|
148
|
+
const rec = await this.get(input.confirmationId);
|
|
149
|
+
if (!rec) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
code: "NOT_FOUND",
|
|
153
|
+
message: `confirmation not found: ${input.confirmationId}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (rec.state === "expired") {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
code: "CONFIRMATION_EXPIRED",
|
|
160
|
+
message: "confirmation expired",
|
|
161
|
+
confirmation: rec,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (rec.state === "consumed") {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
code: "CONFIRMATION_CONSUMED",
|
|
168
|
+
message: "confirmation already consumed",
|
|
169
|
+
confirmation: rec,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (rec.state === "stale") {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
code: "CONFIRMATION_STALE",
|
|
176
|
+
message: rec.staleReason ?? "confirmation is stale",
|
|
177
|
+
confirmation: rec,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (rec.operatorSessionId !== input.operatorSessionId) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
code: "HUMAN_CONFIRMATION_REQUIRED",
|
|
184
|
+
message: "operatorSessionId mismatch; re-run human challenge after session rebuild",
|
|
185
|
+
confirmation: rec,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const drift = detectBindingDrift(rec, current);
|
|
189
|
+
if (drift) {
|
|
190
|
+
const stale = await this.markStale(rec.confirmationId, drift);
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
code: "CONFIRMATION_STALE",
|
|
194
|
+
message: drift,
|
|
195
|
+
confirmation: stale,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const required = new Set(ALL_CONFIRMATION_CHALLENGES);
|
|
199
|
+
for (const c of input.challenges)
|
|
200
|
+
required.delete(c);
|
|
201
|
+
if (required.size > 0) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
code: "HUMAN_CONFIRMATION_REQUIRED",
|
|
205
|
+
message: `missing confirmation challenges: ${[...required].join(", ")}`,
|
|
206
|
+
confirmation: rec,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const now = input.now ?? new Date();
|
|
210
|
+
const confirmed = {
|
|
211
|
+
...rec,
|
|
212
|
+
state: "confirmed",
|
|
213
|
+
confirmedAt: now.toISOString(),
|
|
214
|
+
confirmedChallenges: [...ALL_CONFIRMATION_CHALLENGES],
|
|
215
|
+
};
|
|
216
|
+
await writeSecureJson(confirmationPath(this.appData, rec.confirmationId), confirmed);
|
|
217
|
+
return { ok: true, confirmation: confirmed };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Atomic single-use consume for runDag. Revalidates bindings first.
|
|
221
|
+
*/
|
|
222
|
+
async consume(input) {
|
|
223
|
+
const rec = await this.get(input.confirmationId);
|
|
224
|
+
if (!rec) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
code: "NOT_FOUND",
|
|
228
|
+
message: `confirmation not found: ${input.confirmationId}`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (rec.state === "expired") {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
code: "CONFIRMATION_EXPIRED",
|
|
235
|
+
message: "confirmation expired",
|
|
236
|
+
confirmation: rec,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (rec.state === "consumed") {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
code: "CONFIRMATION_CONSUMED",
|
|
243
|
+
message: "confirmation already consumed (single-use)",
|
|
244
|
+
confirmation: rec,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (rec.state === "stale") {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
code: "CONFIRMATION_STALE",
|
|
251
|
+
message: rec.staleReason ?? "confirmation is stale",
|
|
252
|
+
confirmation: rec,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (rec.state !== "confirmed") {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
code: "HUMAN_CONFIRMATION_REQUIRED",
|
|
259
|
+
message: `confirmation is ${rec.state}; human confirm required before runDag`,
|
|
260
|
+
confirmation: rec,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (rec.operatorSessionId !== input.operatorSessionId) {
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
code: "HUMAN_CONFIRMATION_REQUIRED",
|
|
267
|
+
message: "session mismatch; re-confirm after boot/session rebuild",
|
|
268
|
+
confirmation: rec,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const drift = detectBindingDrift(rec, input.current);
|
|
272
|
+
if (drift) {
|
|
273
|
+
const stale = await this.markStale(rec.confirmationId, drift);
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
code: "CONFIRMATION_STALE",
|
|
277
|
+
message: drift,
|
|
278
|
+
confirmation: stale,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
// Re-check dag hash matches staged bytes path contract
|
|
282
|
+
if (rec.dagSha256 !== input.current.dagSha256) {
|
|
283
|
+
const stale = await this.markStale(rec.confirmationId, "dagSha256 mismatch on consume");
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
code: "CONFIRMATION_STALE",
|
|
287
|
+
message: "dagSha256 mismatch on consume",
|
|
288
|
+
confirmation: stale,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const now = input.now ?? new Date();
|
|
292
|
+
const consumed = {
|
|
293
|
+
...rec,
|
|
294
|
+
state: "consumed",
|
|
295
|
+
consumedAt: now.toISOString(),
|
|
296
|
+
};
|
|
297
|
+
await writeSecureJson(confirmationPath(this.appData, rec.confirmationId), consumed);
|
|
298
|
+
return { ok: true, confirmation: consumed };
|
|
299
|
+
}
|
|
300
|
+
async markStale(confirmationId, reason) {
|
|
301
|
+
const rec = await this.get(confirmationId);
|
|
302
|
+
if (!rec) {
|
|
303
|
+
throw new Error(`confirmation not found: ${confirmationId}`);
|
|
304
|
+
}
|
|
305
|
+
const stale = {
|
|
306
|
+
...rec,
|
|
307
|
+
state: "stale",
|
|
308
|
+
staleReason: reason,
|
|
309
|
+
};
|
|
310
|
+
await writeSecureJson(confirmationPath(this.appData, confirmationId), stale);
|
|
311
|
+
return stale;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { mkdtemp } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { LoopAgentClient } from "../loop-agent/loop-agent-client.js";
|
|
5
|
+
import { preflightTargetRepo } from "../preflight.js";
|
|
6
|
+
import { probePiReadiness, } from "./pi-readiness.js";
|
|
7
|
+
import { DEFAULT_OBSERVE_BASE_URL, OBSERVE_START_COMMAND, probeAndClassifyObserve, } from "./observe-health-match.js";
|
|
8
|
+
import { repoFingerprintV1 } from "./repo-fingerprint.js";
|
|
9
|
+
import { resolveSiblingLoopAgentBin } from "./sibling-controller.js";
|
|
10
|
+
async function probeObserve(url, localFingerprint, fetchImpl) {
|
|
11
|
+
const classified = await probeAndClassifyObserve({
|
|
12
|
+
baseUrl: url,
|
|
13
|
+
localFingerprint,
|
|
14
|
+
fetchImpl,
|
|
15
|
+
});
|
|
16
|
+
if (classified.status === "unavailable") {
|
|
17
|
+
return {
|
|
18
|
+
status: "unavailable",
|
|
19
|
+
url: classified.url,
|
|
20
|
+
message: classified.message,
|
|
21
|
+
match: "unavailable",
|
|
22
|
+
startCommand: OBSERVE_START_COMMAND,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (classified.status === "mismatch") {
|
|
26
|
+
return {
|
|
27
|
+
status: "mismatch",
|
|
28
|
+
url: classified.url,
|
|
29
|
+
body: classified.body,
|
|
30
|
+
match: "mismatch",
|
|
31
|
+
message: classified.message,
|
|
32
|
+
startCommand: OBSERVE_START_COMMAND,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// match — keep status "ok" for backward compatibility; also expose match
|
|
36
|
+
return {
|
|
37
|
+
status: "ok",
|
|
38
|
+
url: classified.url,
|
|
39
|
+
body: classified.body,
|
|
40
|
+
match: "match",
|
|
41
|
+
startCommand: OBSERVE_START_COMMAND,
|
|
42
|
+
message: classified.message,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Run console doctor checks. Observe unavailability never fails the report.
|
|
47
|
+
*/
|
|
48
|
+
export async function runConsoleDoctor(options) {
|
|
49
|
+
const repoRoot = path.resolve(options.repoRoot);
|
|
50
|
+
let fingerprint;
|
|
51
|
+
try {
|
|
52
|
+
fingerprint = repoFingerprintV1(repoRoot);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
fingerprint = `error:${error instanceof Error ? error.message : String(error)}`;
|
|
56
|
+
}
|
|
57
|
+
let siblingBin;
|
|
58
|
+
let controllerIdentity;
|
|
59
|
+
if (options.identity) {
|
|
60
|
+
controllerIdentity = options.identity;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
try {
|
|
64
|
+
siblingBin = options.loopAgentBin ?? resolveSiblingLoopAgentBin();
|
|
65
|
+
const artifactRoot = await mkdtemp(path.join(os.tmpdir(), "console-doctor-"));
|
|
66
|
+
const client = new LoopAgentClient({
|
|
67
|
+
loopAgentBin: siblingBin,
|
|
68
|
+
artifactRoot,
|
|
69
|
+
resolveIdentity: true,
|
|
70
|
+
});
|
|
71
|
+
controllerIdentity = client.getIdentity();
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
controllerIdentity = {
|
|
75
|
+
error: error instanceof Error ? error.message : String(error),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let preflight;
|
|
80
|
+
if (options.preflight) {
|
|
81
|
+
const p = options.preflight;
|
|
82
|
+
preflight = p.ok
|
|
83
|
+
? { ok: true, summary: `version=${p.version}` }
|
|
84
|
+
: { ok: false, code: p.code, message: p.message };
|
|
85
|
+
}
|
|
86
|
+
else if (siblingBin) {
|
|
87
|
+
try {
|
|
88
|
+
const artifactRoot = await mkdtemp(path.join(os.tmpdir(), "console-doctor-preflight-"));
|
|
89
|
+
const client = new LoopAgentClient({
|
|
90
|
+
loopAgentBin: siblingBin,
|
|
91
|
+
artifactRoot,
|
|
92
|
+
resolveIdentity: true,
|
|
93
|
+
});
|
|
94
|
+
const result = await preflightTargetRepo({
|
|
95
|
+
repoRoot,
|
|
96
|
+
client,
|
|
97
|
+
runCheckRepo: false,
|
|
98
|
+
});
|
|
99
|
+
preflight = result.ok
|
|
100
|
+
? { ok: true, summary: `version=${result.version}` }
|
|
101
|
+
: { ok: false, code: result.code, message: result.message };
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
preflight = {
|
|
105
|
+
ok: false,
|
|
106
|
+
code: "preflight-error",
|
|
107
|
+
message: error instanceof Error ? error.message : String(error),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
preflight = {
|
|
113
|
+
ok: false,
|
|
114
|
+
code: "sibling-unavailable",
|
|
115
|
+
message: "cannot run preflight without sibling loop-agent bin",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const observeBase = options.observeBaseUrl ?? DEFAULT_OBSERVE_BASE_URL;
|
|
119
|
+
const observe = await probeObserve(observeBase, fingerprint, options.fetchImpl ?? fetch);
|
|
120
|
+
const pi = await probePiReadiness({
|
|
121
|
+
override: options.piReadinessProbe,
|
|
122
|
+
siblingControllerOk: Boolean(controllerIdentity && "packageVersion" in controllerIdentity),
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
product: "loop-operator-console",
|
|
127
|
+
productName: "Loop Operator Console",
|
|
128
|
+
repoRoot,
|
|
129
|
+
repoFingerprint: fingerprint,
|
|
130
|
+
controllerIdentity,
|
|
131
|
+
siblingLoopAgentBin: siblingBin,
|
|
132
|
+
preflight,
|
|
133
|
+
piReadiness: pi.state,
|
|
134
|
+
pi,
|
|
135
|
+
observe,
|
|
136
|
+
generatedAt: new Date().toISOString(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export function formatConsoleDoctorHuman(report) {
|
|
140
|
+
const lines = [
|
|
141
|
+
"Loop Operator Console — doctor",
|
|
142
|
+
`repoRoot: ${report.repoRoot}`,
|
|
143
|
+
`repoFingerprint: ${report.repoFingerprint}`,
|
|
144
|
+
`piReadiness: ${report.piReadiness}`,
|
|
145
|
+
`pi.sdk: ${report.pi.probe.sdkAvailable}`,
|
|
146
|
+
`pi.auth: ${report.pi.probe.authReady}`,
|
|
147
|
+
`pi.models: ${report.pi.probe.modelsAvailable}`,
|
|
148
|
+
`pi.interviewSession: ${report.pi.probe.interviewSessionOk}`,
|
|
149
|
+
`preflight: ${report.preflight.ok ? `ok (${report.preflight.summary})` : `fail ${report.preflight.code}: ${report.preflight.message}`}`,
|
|
150
|
+
`observe: ${report.observe.status}${report.observe.status === "unavailable" || report.observe.status === "mismatch" ? ` (${report.observe.message})` : ""}`,
|
|
151
|
+
];
|
|
152
|
+
if (report.observe.status === "unavailable" ||
|
|
153
|
+
report.observe.status === "mismatch") {
|
|
154
|
+
lines.push(`observe.startCommand: ${report.observe.startCommand ?? OBSERVE_START_COMMAND}`);
|
|
155
|
+
}
|
|
156
|
+
if (report.siblingLoopAgentBin) {
|
|
157
|
+
lines.push(`siblingLoopAgentBin: ${report.siblingLoopAgentBin}`);
|
|
158
|
+
}
|
|
159
|
+
if (report.controllerIdentity &&
|
|
160
|
+
"packageVersion" in report.controllerIdentity) {
|
|
161
|
+
const id = report.controllerIdentity;
|
|
162
|
+
lines.push(`controller: ${id.packageName}@${id.packageVersion} fingerprint=${id.packageFingerprint.value}`);
|
|
163
|
+
}
|
|
164
|
+
else if (report.controllerIdentity &&
|
|
165
|
+
"error" in report.controllerIdentity) {
|
|
166
|
+
lines.push(`controller: error ${report.controllerIdentity.error}`);
|
|
167
|
+
}
|
|
168
|
+
return `${lines.join("\n")}\n`;
|
|
169
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readJsonIfExists, sha256Json, writeSecureJson, } from "./app-data.js";
|
|
3
|
+
function draftPath(appData, taskId) {
|
|
4
|
+
const safe = taskId.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
5
|
+
return path.join(appData.drafts, `${safe}.json`);
|
|
6
|
+
}
|
|
7
|
+
export function computeDraftSha256(draft) {
|
|
8
|
+
// Hash a stable projection excluding updatedAt so pure timestamp churn
|
|
9
|
+
// does not invalidate assessments — still any field change does.
|
|
10
|
+
const { updatedAt: _u, ...rest } = draft;
|
|
11
|
+
return sha256Json(rest);
|
|
12
|
+
}
|
|
13
|
+
export class DraftStore {
|
|
14
|
+
appData;
|
|
15
|
+
constructor(appData) {
|
|
16
|
+
this.appData = appData;
|
|
17
|
+
}
|
|
18
|
+
getAppData() {
|
|
19
|
+
return this.appData;
|
|
20
|
+
}
|
|
21
|
+
/** Absolute drafts directory (must not be under repo .harness). */
|
|
22
|
+
draftsDir() {
|
|
23
|
+
return this.appData.drafts;
|
|
24
|
+
}
|
|
25
|
+
async get(taskId) {
|
|
26
|
+
const file = draftPath(this.appData, taskId);
|
|
27
|
+
const draft = await readJsonIfExists(file);
|
|
28
|
+
if (!draft)
|
|
29
|
+
return undefined;
|
|
30
|
+
return {
|
|
31
|
+
draft,
|
|
32
|
+
draftSha256: computeDraftSha256(draft),
|
|
33
|
+
path: file,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async save(draft) {
|
|
37
|
+
if (!draft.taskId?.trim()) {
|
|
38
|
+
throw new Error("draft.taskId is required");
|
|
39
|
+
}
|
|
40
|
+
if (draft.schemaVersion !== 1) {
|
|
41
|
+
throw new Error("unsupported draft schemaVersion");
|
|
42
|
+
}
|
|
43
|
+
const next = {
|
|
44
|
+
...draft,
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
updatedAt: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
const file = draftPath(this.appData, next.taskId);
|
|
49
|
+
await writeSecureJson(file, next);
|
|
50
|
+
return {
|
|
51
|
+
draft: next,
|
|
52
|
+
draftSha256: computeDraftSha256(next),
|
|
53
|
+
path: file,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async update(taskId, patch) {
|
|
57
|
+
const current = await this.get(taskId);
|
|
58
|
+
const base = current?.draft ??
|
|
59
|
+
{
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
taskId,
|
|
62
|
+
title: taskId,
|
|
63
|
+
};
|
|
64
|
+
const merged = {
|
|
65
|
+
...base,
|
|
66
|
+
...patch,
|
|
67
|
+
schemaVersion: 1,
|
|
68
|
+
taskId,
|
|
69
|
+
};
|
|
70
|
+
return this.save(merged);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function assertDraftNotInHarness(draftFilePath, repoRoot) {
|
|
74
|
+
const normalizedDraft = path.resolve(draftFilePath);
|
|
75
|
+
const harness = path.resolve(repoRoot, ".harness");
|
|
76
|
+
const prefix = harness.endsWith(path.sep) ? harness : harness + path.sep;
|
|
77
|
+
if (normalizedDraft === harness || normalizedDraft.startsWith(prefix)) {
|
|
78
|
+
throw new Error(`Draft path must not be under repo .harness: ${normalizedDraft}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { createConsoleServer, defaultConsoleStaticDir } from "./server.js";
|
|
2
|
+
export { runConsoleDoctor, formatConsoleDoctorHuman } from "./doctor.js";
|
|
3
|
+
export { isLoopbackHost } from "./loopback.js";
|
|
4
|
+
export { repoFingerprintV1, normalizeWorktreeRealpath, } from "./repo-fingerprint.js";
|
|
5
|
+
export { buildObserveDeepLink } from "./observe-link.js";
|
|
6
|
+
export { classifyObserveHealth, probeAndClassifyObserve, capabilityForObserveTarget, OBSERVE_START_COMMAND, DEFAULT_OBSERVE_BASE_URL, } from "./observe-health-match.js";
|
|
7
|
+
export { allowedRecoveryCtas, classifyRecoveryFact, recoveryMatrixInvariants, } from "./recovery-cta.js";
|
|
8
|
+
export { createClosedResourceLoader, createInterviewResourceLoader, tryActivateTool, assertOfficialToolAllowed, listOfficialTools, OFFICIAL_DENIED_TOOL_IDS, INTERVIEW_ALLOWED_TOOLS, INTERVIEW_DENIED_TOOLS, authorizeInterviewTool, } from "./resource-loader.js";
|
|
9
|
+
export { resolveSiblingLoopAgentBin } from "./sibling-controller.js";
|
|
10
|
+
export { openConsoleAppData, defaultConsoleAppDataRoot } from "./app-data.js";
|
|
11
|
+
export { OperationStore } from "./operation-store.js";
|
|
12
|
+
export { createOperationEventStore } from "./operation-sse.js";
|
|
13
|
+
export { DagConfirmationStore } from "./dag-confirmation.js";
|
|
14
|
+
export { DraftStore } from "./draft-store.js";
|
|
15
|
+
export { probePiReadiness, classifyPiReadiness, isActionBlockedByReadiness, } from "./pi-readiness.js";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readJsonIfExists, writeSecureJson, } from "../app-data.js";
|
|
4
|
+
import { computeDraftSha256, } from "../draft-store.js";
|
|
5
|
+
function assessmentPath(appData, assessmentId) {
|
|
6
|
+
return path.join(appData.assessments, `${assessmentId}.json`);
|
|
7
|
+
}
|
|
8
|
+
export function newAssessmentId() {
|
|
9
|
+
return `assessment_${randomBytes(10).toString("hex")}`;
|
|
10
|
+
}
|
|
11
|
+
export class AssessmentStore {
|
|
12
|
+
appData;
|
|
13
|
+
constructor(appData) {
|
|
14
|
+
this.appData = appData;
|
|
15
|
+
}
|
|
16
|
+
async get(assessmentId) {
|
|
17
|
+
return readJsonIfExists(assessmentPath(this.appData, assessmentId));
|
|
18
|
+
}
|
|
19
|
+
async save(assessment) {
|
|
20
|
+
await writeSecureJson(assessmentPath(this.appData, assessment.assessmentId), assessment);
|
|
21
|
+
return assessment;
|
|
22
|
+
}
|
|
23
|
+
async create(input) {
|
|
24
|
+
const assessment = {
|
|
25
|
+
schemaVersion: 1,
|
|
26
|
+
assessmentId: newAssessmentId(),
|
|
27
|
+
draftSha256: computeDraftSha256(input.draft),
|
|
28
|
+
sourceManifestSha256: input.sourceManifestSha256,
|
|
29
|
+
instructionVersion: input.instructionVersion,
|
|
30
|
+
provider: input.provider,
|
|
31
|
+
model: input.model,
|
|
32
|
+
sessionId: input.sessionId,
|
|
33
|
+
turnId: input.turnId,
|
|
34
|
+
assessedAt: new Date().toISOString(),
|
|
35
|
+
outcome: input.outcome,
|
|
36
|
+
};
|
|
37
|
+
return this.save(assessment);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Fresh complete assessment must bind current draftSha256 (and optional source).
|
|
42
|
+
* Any draft field change → stale (no semantic-equivalence escape hatch).
|
|
43
|
+
*/
|
|
44
|
+
export function isAssessmentFreshForDraft(assessment, draft, options) {
|
|
45
|
+
if (assessment.outcome !== "complete") {
|
|
46
|
+
return {
|
|
47
|
+
fresh: false,
|
|
48
|
+
reason: `assessment outcome is ${assessment.outcome}, need complete`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const currentHash = computeDraftSha256(draft);
|
|
52
|
+
if (assessment.draftSha256 !== currentHash) {
|
|
53
|
+
return {
|
|
54
|
+
fresh: false,
|
|
55
|
+
reason: "draftSha256 mismatch; assessment is stale after Draft change",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (options?.sourceManifestSha256 !== undefined &&
|
|
59
|
+
assessment.sourceManifestSha256 !== undefined &&
|
|
60
|
+
assessment.sourceManifestSha256 !== options.sourceManifestSha256) {
|
|
61
|
+
return {
|
|
62
|
+
fresh: false,
|
|
63
|
+
reason: "sourceManifestSha256 mismatch; assessment is stale",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { fresh: true };
|
|
67
|
+
}
|