@tea-agent/loop-agent 0.16.26 → 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 +38 -1
- 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 +55 -7
- package/dist/executors/pi-executor.js +206 -13
- package/dist/executors/pi-sdk-executor.js +21 -6
- package/dist/executors/shell-executor.js +55 -8
- package/dist/executors/shell-presets.js +16 -3
- 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/observe/health.js +57 -0
- package/dist/worker/observe/routes.js +16 -9
- 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 +269 -116
- package/dist/workflows/dag/node-execution.js +25 -1
- package/dist/workflows/dag/retry-policy.js +16 -1
- package/dist/workflows/dag/runner.js +9 -0
- package/dist/workflows/dag/task-contract-binding.js +138 -0
- package/dist/workflows/dag/types.js +74 -10
- package/dist/workflows/dag/validate.js +25 -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 +49 -2
- 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,312 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { sha256Utf8 } from "./hash.js";
|
|
4
|
+
import { applyStagedToTarget, commitMarkerExists, getTransactionPaths, listTransactionIds, readTransactionIntent, readTransactionState, restoreOriginal, writeCommitMarker, writeTransactionState, } from "./journal.js";
|
|
5
|
+
import { acquireTaskContractLock } from "./lock.js";
|
|
6
|
+
import { appendRequestLedgerEntry, findRequestLedgerEntry, } from "./request-ledger.js";
|
|
7
|
+
import { listUnfinishedTransactionDirs } from "./observe.js";
|
|
8
|
+
import { observeTaskContractState } from "./observe.js";
|
|
9
|
+
import { getTaskPaths } from "../runtime.js";
|
|
10
|
+
import { finalizeTransaction } from "./transaction.js";
|
|
11
|
+
export async function doctorTaskContract(input) {
|
|
12
|
+
const state = await observeTaskContractState(input.repoRoot, input.taskId);
|
|
13
|
+
const paths = getTaskPaths(input.repoRoot, input.taskId);
|
|
14
|
+
const unfinishedIds = await listUnfinishedTransactionDirs(paths.transactionsDir);
|
|
15
|
+
const unfinishedTransactions = unfinishedIds.map((txId) => ({
|
|
16
|
+
txId,
|
|
17
|
+
phase: "preparing",
|
|
18
|
+
path: path.join(paths.transactionsDir, txId),
|
|
19
|
+
}));
|
|
20
|
+
// Enrich phase from state.json
|
|
21
|
+
for (const item of unfinishedTransactions) {
|
|
22
|
+
const txPaths = getTransactionPaths(input.repoRoot, input.taskId, item.txId);
|
|
23
|
+
const st = await readTransactionState(txPaths);
|
|
24
|
+
if (st) {
|
|
25
|
+
item.phase = st.phase;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const recommendations = [];
|
|
29
|
+
if (state.effectiveStatus === "transaction-incomplete") {
|
|
30
|
+
recommendations.push("Run: loop-agent task contract recover --task <id> --json");
|
|
31
|
+
}
|
|
32
|
+
if (state.effectiveStatus === "legacy-unversioned") {
|
|
33
|
+
recommendations.push("Run apply with expectedRevision=0 or: loop-agent task contract adopt --task <id> ...");
|
|
34
|
+
}
|
|
35
|
+
if (state.effectiveStatus === "externally-modified") {
|
|
36
|
+
recommendations.push("Review out-of-band edits, then apply a new Draft or adopt current facts");
|
|
37
|
+
}
|
|
38
|
+
if (unfinishedTransactions.length > 1) {
|
|
39
|
+
recommendations.push("Multiple unfinished transactions detected — manual inspection required (no auto-select)");
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
state,
|
|
43
|
+
unfinishedTransactions: unfinishedTransactions.map((item) => ({
|
|
44
|
+
txId: item.txId,
|
|
45
|
+
phase: item.phase,
|
|
46
|
+
path: item.path,
|
|
47
|
+
})),
|
|
48
|
+
recommendations,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export async function recoverTaskContract(input) {
|
|
52
|
+
const lock = await acquireTaskContractLock({
|
|
53
|
+
repoRoot: input.repoRoot,
|
|
54
|
+
taskId: input.taskId,
|
|
55
|
+
operation: "contract-recover",
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
const paths = getTaskPaths(input.repoRoot, input.taskId);
|
|
59
|
+
const unfinished = await listUnfinishedTransactionDirs(paths.transactionsDir);
|
|
60
|
+
if (unfinished.length === 0) {
|
|
61
|
+
return {
|
|
62
|
+
action: "none",
|
|
63
|
+
message: "no unfinished transactions",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (unfinished.length > 1 && !input.txId) {
|
|
67
|
+
return {
|
|
68
|
+
action: "manual-required",
|
|
69
|
+
message: `multiple unfinished transactions (${unfinished.join(", ")}); pass --tx-id`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const txId = input.txId ?? unfinished[0];
|
|
73
|
+
if (!unfinished.includes(txId)) {
|
|
74
|
+
return {
|
|
75
|
+
action: "manual-required",
|
|
76
|
+
message: `transaction ${txId} is not in unfinished set`,
|
|
77
|
+
txId,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const txPaths = getTransactionPaths(input.repoRoot, input.taskId, txId);
|
|
81
|
+
const intent = await readTransactionIntent(txPaths);
|
|
82
|
+
const state = await readTransactionState(txPaths);
|
|
83
|
+
const committed = await commitMarkerExists(txPaths);
|
|
84
|
+
if (!intent) {
|
|
85
|
+
await writeTransactionState(txPaths, "recovery-required", {
|
|
86
|
+
code: "MISSING_INTENT",
|
|
87
|
+
message: "intent.json missing",
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
action: "manual-required",
|
|
91
|
+
txId,
|
|
92
|
+
message: "intent.json missing; manual recovery required",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const beforeHashes = {};
|
|
96
|
+
const afterHashes = {};
|
|
97
|
+
const taskDir = paths.taskDir;
|
|
98
|
+
for (const target of intent.targets) {
|
|
99
|
+
const abs = path.join(taskDir, ...target.relativePath.split("/"));
|
|
100
|
+
try {
|
|
101
|
+
const bytes = await readFile(abs);
|
|
102
|
+
beforeHashes[target.relativePath] = sha256Utf8(bytes);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
beforeHashes[target.relativePath] = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Case A: commit marker exists (or all targets already match) → finalize only
|
|
109
|
+
if (committed || state?.phase === "committed") {
|
|
110
|
+
// Ensure ledger entry
|
|
111
|
+
const existingLedger = await findRequestLedgerEntry({
|
|
112
|
+
repoRoot: input.repoRoot,
|
|
113
|
+
taskId: input.taskId,
|
|
114
|
+
requestId: intent.requestId,
|
|
115
|
+
});
|
|
116
|
+
if (!existingLedger) {
|
|
117
|
+
await appendRequestLedgerEntry({
|
|
118
|
+
repoRoot: input.repoRoot,
|
|
119
|
+
taskId: input.taskId,
|
|
120
|
+
entry: {
|
|
121
|
+
schemaVersion: 1,
|
|
122
|
+
requestId: intent.requestId,
|
|
123
|
+
requestPayloadSha256: intent.requestPayloadSha256,
|
|
124
|
+
txId,
|
|
125
|
+
revision: intent.nextRevision,
|
|
126
|
+
canonicalHash: intent.nextRef.canonicalHash,
|
|
127
|
+
recordedAt: new Date().toISOString(),
|
|
128
|
+
resultSummary: { ok: true, operation: intent.operation },
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
await finalizeTransaction({
|
|
133
|
+
repoRoot: input.repoRoot,
|
|
134
|
+
taskId: input.taskId,
|
|
135
|
+
txId,
|
|
136
|
+
});
|
|
137
|
+
for (const target of intent.targets) {
|
|
138
|
+
const abs = path.join(taskDir, ...target.relativePath.split("/"));
|
|
139
|
+
try {
|
|
140
|
+
const bytes = await readFile(abs);
|
|
141
|
+
afterHashes[target.relativePath] = sha256Utf8(bytes);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
afterHashes[target.relativePath] = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
action: "finalize",
|
|
149
|
+
txId,
|
|
150
|
+
beforeHashes,
|
|
151
|
+
afterHashes,
|
|
152
|
+
message: "commit marker present; ledger/finalize completed",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// Case B: all targets already match staged hashes → write commit + finalize
|
|
156
|
+
let allMatch = true;
|
|
157
|
+
for (const target of intent.targets) {
|
|
158
|
+
const abs = path.join(taskDir, ...target.relativePath.split("/"));
|
|
159
|
+
try {
|
|
160
|
+
const bytes = await readFile(abs);
|
|
161
|
+
if (sha256Utf8(bytes) !== target.finalSha256) {
|
|
162
|
+
allMatch = false;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
allMatch = false;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (allMatch) {
|
|
172
|
+
await writeCommitMarker(txPaths, {
|
|
173
|
+
txId,
|
|
174
|
+
revision: intent.nextRevision,
|
|
175
|
+
canonicalHash: intent.nextRef.canonicalHash,
|
|
176
|
+
committedAt: new Date().toISOString(),
|
|
177
|
+
});
|
|
178
|
+
await writeTransactionState(txPaths, "committed");
|
|
179
|
+
const existingLedger = await findRequestLedgerEntry({
|
|
180
|
+
repoRoot: input.repoRoot,
|
|
181
|
+
taskId: input.taskId,
|
|
182
|
+
requestId: intent.requestId,
|
|
183
|
+
});
|
|
184
|
+
if (!existingLedger) {
|
|
185
|
+
await appendRequestLedgerEntry({
|
|
186
|
+
repoRoot: input.repoRoot,
|
|
187
|
+
taskId: input.taskId,
|
|
188
|
+
entry: {
|
|
189
|
+
schemaVersion: 1,
|
|
190
|
+
requestId: intent.requestId,
|
|
191
|
+
requestPayloadSha256: intent.requestPayloadSha256,
|
|
192
|
+
txId,
|
|
193
|
+
revision: intent.nextRevision,
|
|
194
|
+
canonicalHash: intent.nextRef.canonicalHash,
|
|
195
|
+
recordedAt: new Date().toISOString(),
|
|
196
|
+
resultSummary: { ok: true, operation: intent.operation },
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
await finalizeTransaction({
|
|
201
|
+
repoRoot: input.repoRoot,
|
|
202
|
+
taskId: input.taskId,
|
|
203
|
+
txId,
|
|
204
|
+
});
|
|
205
|
+
for (const target of intent.targets) {
|
|
206
|
+
afterHashes[target.relativePath] = target.finalSha256;
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
action: "finalize",
|
|
210
|
+
txId,
|
|
211
|
+
beforeHashes,
|
|
212
|
+
afterHashes,
|
|
213
|
+
message: "targets already matched intent; commit+finalize completed",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
// Case C: partial apply — re-apply staged if prepared/applying and staged exists, else rollback
|
|
217
|
+
if (state?.phase === "prepared" || state?.phase === "applying") {
|
|
218
|
+
try {
|
|
219
|
+
for (const target of intent.targets) {
|
|
220
|
+
const abs = path.join(taskDir, ...target.relativePath.split("/"));
|
|
221
|
+
await applyStagedToTarget(txPaths, target.relativePath, abs);
|
|
222
|
+
}
|
|
223
|
+
// verify
|
|
224
|
+
for (const target of intent.targets) {
|
|
225
|
+
const abs = path.join(taskDir, ...target.relativePath.split("/"));
|
|
226
|
+
const bytes = await readFile(abs);
|
|
227
|
+
if (sha256Utf8(bytes) !== target.finalSha256) {
|
|
228
|
+
throw new Error(`hash mismatch after re-apply: ${target.relativePath}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await writeCommitMarker(txPaths, {
|
|
232
|
+
txId,
|
|
233
|
+
revision: intent.nextRevision,
|
|
234
|
+
canonicalHash: intent.nextRef.canonicalHash,
|
|
235
|
+
committedAt: new Date().toISOString(),
|
|
236
|
+
});
|
|
237
|
+
await writeTransactionState(txPaths, "committed");
|
|
238
|
+
const existingLedger = await findRequestLedgerEntry({
|
|
239
|
+
repoRoot: input.repoRoot,
|
|
240
|
+
taskId: input.taskId,
|
|
241
|
+
requestId: intent.requestId,
|
|
242
|
+
});
|
|
243
|
+
if (!existingLedger) {
|
|
244
|
+
await appendRequestLedgerEntry({
|
|
245
|
+
repoRoot: input.repoRoot,
|
|
246
|
+
taskId: input.taskId,
|
|
247
|
+
entry: {
|
|
248
|
+
schemaVersion: 1,
|
|
249
|
+
requestId: intent.requestId,
|
|
250
|
+
requestPayloadSha256: intent.requestPayloadSha256,
|
|
251
|
+
txId,
|
|
252
|
+
revision: intent.nextRevision,
|
|
253
|
+
canonicalHash: intent.nextRef.canonicalHash,
|
|
254
|
+
recordedAt: new Date().toISOString(),
|
|
255
|
+
resultSummary: { ok: true, operation: intent.operation },
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
await finalizeTransaction({
|
|
260
|
+
repoRoot: input.repoRoot,
|
|
261
|
+
taskId: input.taskId,
|
|
262
|
+
txId,
|
|
263
|
+
});
|
|
264
|
+
for (const target of intent.targets) {
|
|
265
|
+
afterHashes[target.relativePath] = target.finalSha256;
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
action: "finalize",
|
|
269
|
+
txId,
|
|
270
|
+
beforeHashes,
|
|
271
|
+
afterHashes,
|
|
272
|
+
message: "re-applied staged files and finalized",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// fall through to rollback
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Rollback from originals
|
|
280
|
+
for (const target of intent.targets) {
|
|
281
|
+
const abs = path.join(taskDir, ...target.relativePath.split("/"));
|
|
282
|
+
await restoreOriginal(txPaths, target.relativePath, abs, target.existedBefore);
|
|
283
|
+
}
|
|
284
|
+
await writeTransactionState(txPaths, "finalized", {
|
|
285
|
+
code: "ROLLED_BACK",
|
|
286
|
+
message: "recovered by rollback to originals",
|
|
287
|
+
});
|
|
288
|
+
for (const target of intent.targets) {
|
|
289
|
+
const abs = path.join(taskDir, ...target.relativePath.split("/"));
|
|
290
|
+
try {
|
|
291
|
+
const bytes = await readFile(abs);
|
|
292
|
+
afterHashes[target.relativePath] = sha256Utf8(bytes);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
afterHashes[target.relativePath] = null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
action: "rollback",
|
|
300
|
+
txId,
|
|
301
|
+
beforeHashes,
|
|
302
|
+
afterHashes,
|
|
303
|
+
message: "rolled back incomplete transaction to originals",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
await lock.release();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
export async function listAllTransactions(repoRoot, taskId) {
|
|
311
|
+
return listTransactionIds(repoRoot, taskId);
|
|
312
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getTaskContractPaths } from "./paths.js";
|
|
4
|
+
export async function readRequestLedger(input) {
|
|
5
|
+
const paths = getTaskContractPaths(input.repoRoot, input.taskId);
|
|
6
|
+
try {
|
|
7
|
+
const raw = await readFile(paths.requestLedgerPath, "utf-8");
|
|
8
|
+
return raw
|
|
9
|
+
.split("\n")
|
|
10
|
+
.map((line) => line.trim())
|
|
11
|
+
.filter(Boolean)
|
|
12
|
+
.map((line) => JSON.parse(line));
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error &&
|
|
16
|
+
typeof error === "object" &&
|
|
17
|
+
"code" in error &&
|
|
18
|
+
error.code === "ENOENT") {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function findRequestLedgerEntry(input) {
|
|
25
|
+
const entries = await readRequestLedger(input);
|
|
26
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
27
|
+
const entry = entries[i];
|
|
28
|
+
if (entry?.requestId === input.requestId)
|
|
29
|
+
return entry;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
export async function appendRequestLedgerEntry(input) {
|
|
34
|
+
const paths = getTaskContractPaths(input.repoRoot, input.taskId);
|
|
35
|
+
await mkdir(path.dirname(paths.requestLedgerPath), { recursive: true });
|
|
36
|
+
await appendFile(paths.requestLedgerPath, `${JSON.stringify(input.entry)}\n`, "utf-8");
|
|
37
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { taskKindSchema, taskVerifyCommandSchema } from "../config-types.js";
|
|
3
|
+
import { TASK_CONFIG_SCHEMA_VERSION, TASK_CONTRACT_CANONICALIZER_VERSION, TASK_CONTRACT_DRAFT_SCHEMA_VERSION, TASK_CONTRACT_PROJECTION_VERSION, TASK_CONTRACT_REF_SCHEMA_VERSION, } from "./constants.js";
|
|
4
|
+
const sha256Schema = z
|
|
5
|
+
.string()
|
|
6
|
+
.regex(/^[a-f0-9]{64}$/, "sha256 must be lowercase hex");
|
|
7
|
+
export const taskContractDraftRequirementSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
objective: z.string().min(1),
|
|
10
|
+
scope: z.array(z.string()),
|
|
11
|
+
nonGoals: z.array(z.string()),
|
|
12
|
+
acceptanceCriteria: z
|
|
13
|
+
.array(z
|
|
14
|
+
.object({
|
|
15
|
+
id: z.string().min(1),
|
|
16
|
+
text: z.string().min(1),
|
|
17
|
+
})
|
|
18
|
+
.strict())
|
|
19
|
+
.min(1),
|
|
20
|
+
})
|
|
21
|
+
.strict();
|
|
22
|
+
export const taskContractDraftConstraintsSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
invariants: z.array(z.string()),
|
|
25
|
+
allowedPaths: z.array(z.string()),
|
|
26
|
+
forbiddenPaths: z.array(z.string()),
|
|
27
|
+
protectedUserChanges: z.array(z.string()).optional(),
|
|
28
|
+
expectedVerification: z.array(z.string()).optional(),
|
|
29
|
+
failureConditions: z.array(z.string()).optional(),
|
|
30
|
+
knownRisks: z.array(z.string()).optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
export const taskContractDraftVerificationSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
commands: z.array(taskVerifyCommandSchema),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
export const taskContractDraftReferenceSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
role: z.string().min(1),
|
|
41
|
+
ref: z.string().min(1),
|
|
42
|
+
})
|
|
43
|
+
.strict();
|
|
44
|
+
export const taskContractDraftSourceResolutionSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
sourceRef: z.string().min(1),
|
|
47
|
+
conflict: z.string().min(1),
|
|
48
|
+
decision: z.string().min(1),
|
|
49
|
+
reason: z.string().min(1),
|
|
50
|
+
decidedBy: z.enum(["human"]),
|
|
51
|
+
})
|
|
52
|
+
.strict();
|
|
53
|
+
export const taskContractDraftSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
schemaVersion: z.literal(TASK_CONTRACT_DRAFT_SCHEMA_VERSION),
|
|
56
|
+
taskId: z.string().min(1),
|
|
57
|
+
title: z.string().min(1),
|
|
58
|
+
taskKind: taskKindSchema,
|
|
59
|
+
featureId: z.string().min(1).optional(),
|
|
60
|
+
requirement: taskContractDraftRequirementSchema,
|
|
61
|
+
constraints: taskContractDraftConstraintsSchema,
|
|
62
|
+
verification: taskContractDraftVerificationSchema,
|
|
63
|
+
references: z.array(taskContractDraftReferenceSchema).optional(),
|
|
64
|
+
sourceResolutions: z
|
|
65
|
+
.array(taskContractDraftSourceResolutionSchema)
|
|
66
|
+
.optional(),
|
|
67
|
+
openQuestions: z.array(z.string()).optional(),
|
|
68
|
+
assumptions: z.array(z.string()).optional(),
|
|
69
|
+
hardConstraints: z.array(z.string()).optional(),
|
|
70
|
+
interviewAssessmentRef: z
|
|
71
|
+
.object({
|
|
72
|
+
draftSha256: z.string().min(1),
|
|
73
|
+
assessmentId: z.string().min(1),
|
|
74
|
+
})
|
|
75
|
+
.strict()
|
|
76
|
+
.optional(),
|
|
77
|
+
})
|
|
78
|
+
.strict()
|
|
79
|
+
.superRefine((draft, ctx) => {
|
|
80
|
+
if (draft.taskKind === "knowledge-sync" && !draft.featureId) {
|
|
81
|
+
ctx.addIssue({
|
|
82
|
+
code: z.ZodIssueCode.custom,
|
|
83
|
+
message: "featureId is required when taskKind is knowledge-sync",
|
|
84
|
+
path: ["featureId"],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const allowed = new Set(draft.constraints.allowedPaths);
|
|
88
|
+
for (const forbidden of draft.constraints.forbiddenPaths) {
|
|
89
|
+
if (allowed.has(forbidden)) {
|
|
90
|
+
ctx.addIssue({
|
|
91
|
+
code: z.ZodIssueCode.custom,
|
|
92
|
+
message: `forbiddenPaths entry overlaps allowedPaths: ${forbidden}`,
|
|
93
|
+
path: ["constraints", "forbiddenPaths"],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
export const taskContractSourceHashesSchema = z
|
|
99
|
+
.object({
|
|
100
|
+
requirementSha256: sha256Schema,
|
|
101
|
+
constraintsSha256: sha256Schema,
|
|
102
|
+
referenceManifestSha256: sha256Schema.optional(),
|
|
103
|
+
})
|
|
104
|
+
.strict();
|
|
105
|
+
export const taskContractRefSchema = z
|
|
106
|
+
.object({
|
|
107
|
+
schemaVersion: z.literal(TASK_CONTRACT_REF_SCHEMA_VERSION),
|
|
108
|
+
taskId: z.string().min(1),
|
|
109
|
+
revision: z.number().int().nonnegative(),
|
|
110
|
+
projectionVersion: z.literal(TASK_CONTRACT_PROJECTION_VERSION),
|
|
111
|
+
canonicalizerVersion: z.literal(TASK_CONTRACT_CANONICALIZER_VERSION),
|
|
112
|
+
taskConfigSchemaVersion: z.literal(TASK_CONFIG_SCHEMA_VERSION),
|
|
113
|
+
sourceHashes: taskContractSourceHashesSchema,
|
|
114
|
+
taskConfigSha256: sha256Schema,
|
|
115
|
+
canonicalHash: sha256Schema,
|
|
116
|
+
updatedAt: z.string().min(1),
|
|
117
|
+
lastRequestId: z.string().min(1).optional(),
|
|
118
|
+
})
|
|
119
|
+
.strict();
|
|
120
|
+
export function parseTaskContractDraft(raw) {
|
|
121
|
+
return taskContractDraftSchema.parse(raw);
|
|
122
|
+
}
|
|
123
|
+
export function safeParseTaskContractDraft(raw) {
|
|
124
|
+
return taskContractDraftSchema.safeParse(raw);
|
|
125
|
+
}
|
|
126
|
+
export function parseTaskContractRef(raw) {
|
|
127
|
+
return taskContractRefSchema.parse(raw);
|
|
128
|
+
}
|
|
129
|
+
export function safeParseTaskContractRef(raw) {
|
|
130
|
+
return taskContractRefSchema.safeParse(raw);
|
|
131
|
+
}
|
|
132
|
+
/** Additional business rules beyond zod schema. */
|
|
133
|
+
export function validateDraftBusinessRules(draft) {
|
|
134
|
+
const errors = [];
|
|
135
|
+
if (draft.taskKind === "knowledge-sync" && !draft.featureId) {
|
|
136
|
+
errors.push("featureId is required when taskKind is knowledge-sync");
|
|
137
|
+
}
|
|
138
|
+
if (!draft.requirement.objective.trim()) {
|
|
139
|
+
errors.push("requirement.objective must be non-empty");
|
|
140
|
+
}
|
|
141
|
+
if (draft.requirement.acceptanceCriteria.length === 0) {
|
|
142
|
+
errors.push("at least one acceptance criterion is required");
|
|
143
|
+
}
|
|
144
|
+
const allowed = new Set(draft.constraints.allowedPaths);
|
|
145
|
+
for (const forbidden of draft.constraints.forbiddenPaths) {
|
|
146
|
+
if (allowed.has(forbidden)) {
|
|
147
|
+
errors.push(`forbiddenPaths entry overlaps allowedPaths: ${forbidden}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return errors;
|
|
151
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONSTRAINTS_FILE_NAME, CONTRACT_FILE_NAME, REQUIREMENT_FILE_NAME, } from "./constants.js";
|
|
4
|
+
import { applyStagedToTarget, backupOriginalIfExists, commitMarkerExists, ensureTransactionDirs, getTransactionPaths, readTransactionIntent, readTransactionState, restoreOriginal, stageJsonFile, stageTextFile, writeCommitMarker, writeTransactionIntent, writeTransactionState, } from "./journal.js";
|
|
5
|
+
import { sha256Utf8 } from "./hash.js";
|
|
6
|
+
import { getTaskPaths } from "../runtime.js";
|
|
7
|
+
function targetAbs(taskDir, relativePath) {
|
|
8
|
+
return path.join(taskDir, ...relativePath.split("/"));
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Run the journaled multi-file commit protocol for contract mutations.
|
|
12
|
+
* commit.json is the unique logical commit point.
|
|
13
|
+
*/
|
|
14
|
+
export async function runJournaledContractCommit(input) {
|
|
15
|
+
const paths = getTaskPaths(input.repoRoot, input.taskId);
|
|
16
|
+
const txId = `tx-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
17
|
+
const txPaths = getTransactionPaths(input.repoRoot, input.taskId, txId);
|
|
18
|
+
await ensureTransactionDirs(txPaths);
|
|
19
|
+
await writeTransactionState(txPaths, "preparing");
|
|
20
|
+
const requirementRel = `source/${REQUIREMENT_FILE_NAME}`;
|
|
21
|
+
const constraintsRel = `source/${CONSTRAINTS_FILE_NAME}`;
|
|
22
|
+
const taskConfigRel = "task.json";
|
|
23
|
+
const contractRel = CONTRACT_FILE_NAME;
|
|
24
|
+
const targetSpecs = [
|
|
25
|
+
{
|
|
26
|
+
kind: "requirement",
|
|
27
|
+
relativePath: requirementRel,
|
|
28
|
+
content: input.targets.requirementContent,
|
|
29
|
+
isJson: false,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
kind: "constraints",
|
|
33
|
+
relativePath: constraintsRel,
|
|
34
|
+
content: input.targets.constraintsContent,
|
|
35
|
+
isJson: false,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
kind: "task-config",
|
|
39
|
+
relativePath: taskConfigRel,
|
|
40
|
+
content: input.targets.taskConfigContent,
|
|
41
|
+
isJson: false,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
kind: "contract",
|
|
45
|
+
relativePath: contractRel,
|
|
46
|
+
content: input.targets.contractRef,
|
|
47
|
+
isJson: true,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
const targetRecords = [];
|
|
51
|
+
for (const spec of targetSpecs) {
|
|
52
|
+
const abs = targetAbs(paths.taskDir, spec.relativePath);
|
|
53
|
+
const backup = await backupOriginalIfExists(txPaths, abs, spec.relativePath);
|
|
54
|
+
const originalSha256 = backup.content
|
|
55
|
+
? sha256Utf8(backup.content)
|
|
56
|
+
: null;
|
|
57
|
+
if (spec.isJson) {
|
|
58
|
+
await stageJsonFile(txPaths, spec.relativePath, spec.content, input.repoRoot);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
await stageTextFile(txPaths, spec.relativePath, spec.content, input.repoRoot);
|
|
62
|
+
}
|
|
63
|
+
// Read staged bytes for hash
|
|
64
|
+
const { readFile } = await import("node:fs/promises");
|
|
65
|
+
const stagedBytes = await readFile(path.join(txPaths.stagedDir, spec.relativePath));
|
|
66
|
+
const stagedSha256 = sha256Utf8(stagedBytes);
|
|
67
|
+
targetRecords.push({
|
|
68
|
+
kind: spec.kind,
|
|
69
|
+
relativePath: spec.relativePath,
|
|
70
|
+
existedBefore: backup.existed,
|
|
71
|
+
originalSha256,
|
|
72
|
+
stagedSha256,
|
|
73
|
+
finalSha256: stagedSha256,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const intent = {
|
|
77
|
+
schemaVersion: 1,
|
|
78
|
+
txId,
|
|
79
|
+
taskId: input.taskId,
|
|
80
|
+
operation: input.operation,
|
|
81
|
+
requestId: input.requestId,
|
|
82
|
+
requestPayloadSha256: input.requestPayloadSha256,
|
|
83
|
+
expectedRevision: input.expectedRevision,
|
|
84
|
+
expectedObservedCanonicalHash: input.expectedObservedCanonicalHash,
|
|
85
|
+
nextRevision: input.nextRevision,
|
|
86
|
+
fencingToken: input.fencingToken,
|
|
87
|
+
createdAt: new Date().toISOString(),
|
|
88
|
+
targets: targetRecords,
|
|
89
|
+
nextRef: input.targets.contractRef,
|
|
90
|
+
};
|
|
91
|
+
await writeTransactionIntent(txPaths, intent);
|
|
92
|
+
await writeTransactionState(txPaths, "prepared");
|
|
93
|
+
await writeTransactionState(txPaths, "applying");
|
|
94
|
+
try {
|
|
95
|
+
for (const record of targetRecords) {
|
|
96
|
+
const abs = targetAbs(paths.taskDir, record.relativePath);
|
|
97
|
+
await applyStagedToTarget(txPaths, record.relativePath, abs);
|
|
98
|
+
}
|
|
99
|
+
// Verify applied hashes match intent
|
|
100
|
+
const { readFile } = await import("node:fs/promises");
|
|
101
|
+
for (const record of targetRecords) {
|
|
102
|
+
const abs = targetAbs(paths.taskDir, record.relativePath);
|
|
103
|
+
const applied = await readFile(abs);
|
|
104
|
+
const appliedHash = sha256Utf8(applied);
|
|
105
|
+
if (appliedHash !== record.finalSha256) {
|
|
106
|
+
throw new Error(`post-apply hash mismatch for ${record.relativePath}: expected ${record.finalSha256}, got ${appliedHash}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Unique logical commit point
|
|
110
|
+
await writeCommitMarker(txPaths, {
|
|
111
|
+
txId,
|
|
112
|
+
revision: input.nextRevision,
|
|
113
|
+
canonicalHash: input.targets.contractRef.canonicalHash,
|
|
114
|
+
committedAt: new Date().toISOString(),
|
|
115
|
+
});
|
|
116
|
+
await writeTransactionState(txPaths, "committed");
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// Only rollback if commit marker not written
|
|
120
|
+
const committed = await commitMarkerExists(txPaths);
|
|
121
|
+
if (!committed) {
|
|
122
|
+
for (const record of targetRecords) {
|
|
123
|
+
const abs = targetAbs(paths.taskDir, record.relativePath);
|
|
124
|
+
try {
|
|
125
|
+
await restoreOriginal(txPaths, record.relativePath, abs, record.existedBefore);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// best-effort restore
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
await writeTransactionState(txPaths, "recovery-required", {
|
|
132
|
+
code: "APPLY_FAILED",
|
|
133
|
+
message: error instanceof Error ? error.message : String(error),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
await writeTransactionState(txPaths, "committed", {
|
|
138
|
+
code: "POST_COMMIT_ERROR",
|
|
139
|
+
message: error instanceof Error ? error.message : String(error),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
return { txId, ref: input.targets.contractRef };
|
|
145
|
+
}
|
|
146
|
+
export async function finalizeTransaction(input) {
|
|
147
|
+
const txPaths = getTransactionPaths(input.repoRoot, input.taskId, input.txId);
|
|
148
|
+
const state = await readTransactionState(txPaths);
|
|
149
|
+
if (!state) {
|
|
150
|
+
throw new Error(`transaction state missing: ${input.txId}`);
|
|
151
|
+
}
|
|
152
|
+
if (state.phase === "finalized")
|
|
153
|
+
return;
|
|
154
|
+
const committed = await commitMarkerExists(txPaths);
|
|
155
|
+
if (!committed && state.phase !== "committed") {
|
|
156
|
+
throw new Error(`cannot finalize non-committed transaction ${input.txId} phase=${state.phase}`);
|
|
157
|
+
}
|
|
158
|
+
await writeTransactionState(txPaths, "finalized");
|
|
159
|
+
}
|
|
160
|
+
export { readTransactionIntent, readTransactionState, getTransactionPaths };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|