@tea-agent/loop-agent 0.8.0 → 0.10.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/AGENTS.md +2 -0
- package/CHANGELOG.md +51 -1
- package/README.md +20 -0
- package/dist/application/dag/args.js +9 -2
- package/dist/cli/command-definitions.js +7 -0
- package/dist/cli/program.js +6 -1
- package/dist/commands/dag-reconcile-run.js +118 -0
- package/dist/commands/init.js +12 -3
- package/dist/executors/shell-executor.js +74 -8
- package/dist/governance/manifest-types.js +4 -0
- package/dist/shared/reference-context.js +48 -22
- package/dist/task/config-types.js +1 -1
- package/dist/task/runtime.js +1 -1
- package/dist/worker/cli.js +216 -0
- package/dist/worker/closeout/apply.js +73 -0
- package/dist/worker/closeout/preview.js +30 -0
- package/dist/worker/delivery/final-verification.js +158 -0
- package/dist/worker/delivery/git-transaction.js +354 -0
- package/dist/worker/delivery/package.js +449 -0
- package/dist/worker/feature/decision-loader.js +68 -0
- package/dist/worker/feature/discover.js +14 -0
- package/dist/worker/feature/next-action.js +74 -0
- package/dist/worker/feature/reducer.js +133 -0
- package/dist/worker/feature/review.js +502 -0
- package/dist/worker/feature/run.js +313 -0
- package/dist/worker/feature/types.js +1 -0
- package/dist/worker/follow-up/approve.js +270 -0
- package/dist/worker/follow-up/factory.js +234 -0
- package/dist/worker/follow-up/paths.js +25 -0
- package/dist/worker/follow-up/policy.js +26 -0
- package/dist/worker/follow-up/schema.js +93 -0
- package/dist/worker/follow-up/store.js +96 -0
- package/dist/worker/loop-agent/loop-agent-client.js +51 -10
- package/dist/worker/metrics/projector.js +139 -0
- package/dist/worker/observability/read-model.js +256 -15
- package/dist/worker/observe/paths.js +17 -5
- package/dist/worker/observe/routes.js +78 -20
- package/dist/worker/observe/server.js +8 -6
- package/dist/worker/observe/static/app.js +1045 -177
- package/dist/worker/observe/static/index.html +70 -43
- package/dist/worker/observe/static/styles.css +553 -610
- package/dist/worker/pool/run-store.js +14 -2
- package/dist/worker/pool/validation.js +59 -0
- package/dist/worker/report/morning-report.js +41 -6
- package/dist/worker/run-task/run-task.js +1 -1
- package/dist/worker/runner/run-ready.js +19 -5
- package/dist/workflows/dag/init-hybrid.js +3 -1
- package/dist/workflows/dag/lifecycle.js +146 -0
- package/dist/workflows/dag/node-execution.js +3 -0
- package/dist/workflows/dag/prompt.js +16 -0
- package/dist/workflows/dag/report.js +2 -0
- package/dist/workflows/dag/runner.js +133 -104
- package/dist/workflows/dag/types.js +3 -0
- package/docs/README.md +21 -0
- package/docs/agent-dag-recovery-playbook.md +1 -1
- package/docs/architecture/runtime-boundaries.md +3 -2
- package/docs/design/README.md +13 -7
- package/docs/exec-plans/active/README.md +2 -2
- package/docs/exec-plans/completed/README.md +15 -0
- package/docs/loop-agent-harness.md +45 -2
- package/docs/progress/README.md +2 -0
- package/docs/reports/README.md +13 -0
- package/docs/templates/agent-dag-report.schema.json +5 -3
- package/docs/templates/harness.schema.json +7 -2
- package/docs/templates/init-evolution-review.md +4 -2
- package/docs/verification-matrix.md +7 -0
- package/harness.json +4 -3
- package/package.json +4 -2
- package/scripts/check-product-line-docs.sh +7 -3
- package/scripts/check-task-pool-root.sh +1 -1
- package/skills/init-capability-evolution/SKILL.md +1 -0
- package/skills/loop-agent/references/command-reference.md +21 -0
- package/skills/loop-agent/references/hybrid-dag.md +4 -3
- package/skills/loop-agent/references/verification-and-failure-handling.md +8 -3
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { cp, lstat, mkdir, readFile, readlink, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { getTaskPoolRoot } from "../pool/run-store.js";
|
|
8
|
+
import { assertSafeRuntimeId } from "../follow-up/paths.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const checkpointSchema = z.object({
|
|
11
|
+
taskId: z.string().min(1),
|
|
12
|
+
workerRunId: z.string().min(1),
|
|
13
|
+
commit: z.string().regex(/^[a-f0-9]{40}$/),
|
|
14
|
+
changedFiles: z.array(z.string()),
|
|
15
|
+
createdAt: z.string().datetime(),
|
|
16
|
+
}).strict();
|
|
17
|
+
const ignoredSensitiveBaselineEntrySchema = z.object({
|
|
18
|
+
path: z.string().min(1),
|
|
19
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
20
|
+
}).strict();
|
|
21
|
+
export const gitTransactionRecordSchema = z.object({
|
|
22
|
+
schemaVersion: z.literal(1),
|
|
23
|
+
featureId: z.string().min(1),
|
|
24
|
+
branch: z.string().min(1),
|
|
25
|
+
baseBranch: z.string().min(1),
|
|
26
|
+
baseCommit: z.string().regex(/^[a-f0-9]{40}$/),
|
|
27
|
+
lastCheckpoint: z.string().regex(/^[a-f0-9]{40}$/),
|
|
28
|
+
authorizedBy: z.literal("cli --git-mode checkpoint"),
|
|
29
|
+
startedAt: z.string().datetime(),
|
|
30
|
+
ignoredBaseline: z.array(ignoredSensitiveBaselineEntrySchema),
|
|
31
|
+
ignoredSensitiveBaseline: z.array(ignoredSensitiveBaselineEntrySchema),
|
|
32
|
+
checkpoints: z.array(checkpointSchema),
|
|
33
|
+
}).strict();
|
|
34
|
+
export async function startGitTransaction(input) {
|
|
35
|
+
const repoRoot = await realpath(path.resolve(input.repoRoot));
|
|
36
|
+
assertSafeRuntimeId(input.featureId, "featureId");
|
|
37
|
+
const gitRoot = await git(repoRoot, ["rev-parse", "--show-toplevel"]);
|
|
38
|
+
if (await realpath(gitRoot) !== repoRoot)
|
|
39
|
+
throw new Error(`Git root does not match target repo: ${gitRoot}`);
|
|
40
|
+
const head = await git(repoRoot, ["rev-parse", "HEAD"]);
|
|
41
|
+
const baseBranch = await git(repoRoot, ["branch", "--show-current"]);
|
|
42
|
+
if (!baseBranch)
|
|
43
|
+
throw new Error("Git checkpoint requires a named current branch");
|
|
44
|
+
await assertClean(repoRoot);
|
|
45
|
+
const branch = input.branch ?? `agent/${input.featureId.toLowerCase()}`;
|
|
46
|
+
assertSafeBranch(branch);
|
|
47
|
+
const recordPath = transactionRecordPath(repoRoot, input.featureId);
|
|
48
|
+
const existing = await readOptionalRecord(recordPath);
|
|
49
|
+
const branchExists = await gitOk(repoRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
50
|
+
if (branchExists) {
|
|
51
|
+
if (!existing)
|
|
52
|
+
throw new Error(`Feature branch already exists without transaction ownership: ${branch}`);
|
|
53
|
+
if (existing.branch !== branch || existing.featureId !== input.featureId)
|
|
54
|
+
throw new Error(`Feature branch ownership mismatch: ${branch}`);
|
|
55
|
+
if (baseBranch === existing.baseBranch && head !== existing.baseCommit)
|
|
56
|
+
throw new Error(`Feature base branch moved from recorded commit: ${existing.baseCommit}`);
|
|
57
|
+
if (baseBranch === branch && head !== existing.lastCheckpoint)
|
|
58
|
+
throw new Error(`Feature branch HEAD does not match recorded checkpoint: ${head}`);
|
|
59
|
+
if (baseBranch !== existing.baseBranch && baseBranch !== branch)
|
|
60
|
+
throw new Error(`Current branch is not owned by this Feature transaction: ${baseBranch}`);
|
|
61
|
+
await git(repoRoot, ["switch", branch]);
|
|
62
|
+
const branchHead = await git(repoRoot, ["rev-parse", "HEAD"]);
|
|
63
|
+
if (branchHead !== existing.lastCheckpoint)
|
|
64
|
+
throw new Error(`Feature branch HEAD does not match recorded checkpoint: ${branchHead}`);
|
|
65
|
+
return { repoRoot, recordPath, record: existing };
|
|
66
|
+
}
|
|
67
|
+
if (existing)
|
|
68
|
+
throw new Error(`Git transaction record exists but branch is missing: ${branch}`);
|
|
69
|
+
await git(repoRoot, ["switch", "-c", branch]);
|
|
70
|
+
const ignoredBaseline = await readIgnoredBaseline(repoRoot);
|
|
71
|
+
const record = {
|
|
72
|
+
schemaVersion: 1,
|
|
73
|
+
featureId: input.featureId,
|
|
74
|
+
branch,
|
|
75
|
+
baseBranch,
|
|
76
|
+
baseCommit: head,
|
|
77
|
+
lastCheckpoint: head,
|
|
78
|
+
authorizedBy: "cli --git-mode checkpoint",
|
|
79
|
+
startedAt: (input.now ?? new Date()).toISOString(),
|
|
80
|
+
ignoredBaseline,
|
|
81
|
+
ignoredSensitiveBaseline: ignoredBaseline.filter((entry) => isSensitivePath(entry.path)),
|
|
82
|
+
checkpoints: [],
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
await writeJsonAtomic(recordPath, record);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
await git(repoRoot, ["switch", baseBranch]).catch(() => { });
|
|
89
|
+
await git(repoRoot, ["branch", "-D", branch]).catch(() => { });
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
return { repoRoot, recordPath, record };
|
|
93
|
+
}
|
|
94
|
+
export async function finalizeGitTask(transaction, outcome) {
|
|
95
|
+
const current = gitTransactionRecordSchema.parse(JSON.parse(await readFile(transaction.recordPath, "utf-8")));
|
|
96
|
+
if (JSON.stringify(current) !== JSON.stringify(transaction.record))
|
|
97
|
+
throw new Error("Git transaction record changed outside the finalizer");
|
|
98
|
+
await assertTransactionPosition(transaction.repoRoot, current);
|
|
99
|
+
const currentIgnored = await readIgnoredBaseline(transaction.repoRoot);
|
|
100
|
+
const ignoredBaselineByPath = new Map(current.ignoredBaseline.map((entry) => [entry.path, entry.sha256]));
|
|
101
|
+
const changedBaselineIgnored = currentIgnored.filter((entry) => ignoredBaselineByPath.has(entry.path) && ignoredBaselineByPath.get(entry.path) !== entry.sha256).map((entry) => entry.path);
|
|
102
|
+
const missingBaselineIgnored = current.ignoredBaseline.filter((entry) => !currentIgnored.some((candidate) => candidate.path === entry.path)).map((entry) => entry.path);
|
|
103
|
+
if (changedBaselineIgnored.length > 0 || missingBaselineIgnored.length > 0) {
|
|
104
|
+
throw new Error(`pre-existing ignored file changed during Git transaction: ${[...changedBaselineIgnored, ...missingBaselineIgnored].join(", ")}`);
|
|
105
|
+
}
|
|
106
|
+
const existing = current.checkpoints.find((entry) => entry.workerRunId === outcome.workerRunId);
|
|
107
|
+
if (outcome.status === "reused" || existing) {
|
|
108
|
+
if (!existing)
|
|
109
|
+
throw new Error(`reused Worker run has no recorded checkpoint: ${outcome.workerRunId}`);
|
|
110
|
+
if (existing.taskId !== outcome.taskSpec.id || current.featureId !== outcome.taskSpec.feature_id)
|
|
111
|
+
throw new Error(`reused Worker run does not match current TaskSpec: ${outcome.workerRunId}`);
|
|
112
|
+
if (!await gitOk(transaction.repoRoot, ["cat-file", "-e", `${existing.commit}^{commit}`]))
|
|
113
|
+
throw new Error(`recorded checkpoint is missing from Git history: ${existing.commit}`);
|
|
114
|
+
if (!await gitOk(transaction.repoRoot, ["merge-base", "--is-ancestor", existing.commit, current.lastCheckpoint]))
|
|
115
|
+
throw new Error(`recorded checkpoint is outside the Feature transaction history: ${existing.commit}`);
|
|
116
|
+
await assertCheckpointMetadata(transaction.repoRoot, existing.commit, outcome.taskSpec, existing.workerRunId);
|
|
117
|
+
return { status: "reused", taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit: existing.commit, changedFiles: existing.changedFiles };
|
|
118
|
+
}
|
|
119
|
+
const changes = await readChanges(transaction.repoRoot);
|
|
120
|
+
const newIgnored = currentIgnored.map((entry) => entry.path).filter((entry) => !ignoredBaselineByPath.has(entry));
|
|
121
|
+
if (outcome.status === "succeeded") {
|
|
122
|
+
if (changes.length === 0)
|
|
123
|
+
throw new Error(`successful task produced no checkpointable changes: ${outcome.taskSpec.id}`);
|
|
124
|
+
if (newIgnored.length > 0)
|
|
125
|
+
throw new Error(`task created ignored files outside the Git checkpoint: ${newIgnored.join(", ")}`);
|
|
126
|
+
auditChangedPaths(changes, outcome.taskSpec);
|
|
127
|
+
await git(transaction.repoRoot, ["add", "--", ...changes]);
|
|
128
|
+
const message = commitMessage(outcome.taskSpec, outcome.workerRunId);
|
|
129
|
+
await git(transaction.repoRoot, ["commit", "-m", message]);
|
|
130
|
+
const commit = await git(transaction.repoRoot, ["rev-parse", "HEAD"]);
|
|
131
|
+
const checkpoint = { taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: changes, createdAt: (outcome.now ?? new Date()).toISOString() };
|
|
132
|
+
const previousCheckpoint = current.lastCheckpoint;
|
|
133
|
+
try {
|
|
134
|
+
await assertTransactionPosition(transaction.repoRoot, { ...current, lastCheckpoint: commit });
|
|
135
|
+
await assertCheckpointMetadata(transaction.repoRoot, commit, outcome.taskSpec, outcome.workerRunId);
|
|
136
|
+
const afterCommitIgnored = await readIgnoredBaseline(transaction.repoRoot);
|
|
137
|
+
assertIgnoredBaselineUnchanged(current.ignoredBaseline, afterCommitIgnored);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
const branch = await git(transaction.repoRoot, ["branch", "--show-current"]).catch(() => "");
|
|
141
|
+
if (branch === current.branch) {
|
|
142
|
+
await git(transaction.repoRoot, ["reset", "--hard", previousCheckpoint]).catch(() => { });
|
|
143
|
+
const afterFailureIgnored = await readIgnoredBaseline(transaction.repoRoot).catch(() => []);
|
|
144
|
+
const baselinePaths = new Set(current.ignoredBaseline.map((entry) => entry.path));
|
|
145
|
+
for (const entry of afterFailureIgnored)
|
|
146
|
+
if (!baselinePaths.has(entry.path))
|
|
147
|
+
await rm(path.join(transaction.repoRoot, entry.path), { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
current.lastCheckpoint = commit;
|
|
152
|
+
current.checkpoints.push(checkpoint);
|
|
153
|
+
try {
|
|
154
|
+
await writeJsonAtomic(transaction.recordPath, current);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
await git(transaction.repoRoot, ["reset", "--hard", previousCheckpoint]).catch(() => { });
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
transaction.record = current;
|
|
161
|
+
await assertClean(transaction.repoRoot);
|
|
162
|
+
return { status: "checkpointed", taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: changes };
|
|
163
|
+
}
|
|
164
|
+
const artifactDir = path.join(path.dirname(transaction.recordPath), "failures", outcome.workerRunId);
|
|
165
|
+
await captureFailureArtifacts(transaction.repoRoot, artifactDir, changes, newIgnored, outcome);
|
|
166
|
+
if (outcome.keepFailedDiff) {
|
|
167
|
+
return { status: "kept-failed-diff", taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, artifactDir, changedFiles: changes };
|
|
168
|
+
}
|
|
169
|
+
await git(transaction.repoRoot, ["reset", "--hard", current.lastCheckpoint]);
|
|
170
|
+
await git(transaction.repoRoot, ["clean", "-fd"]);
|
|
171
|
+
for (const relative of newIgnored)
|
|
172
|
+
await rm(path.join(transaction.repoRoot, relative), { recursive: true, force: true });
|
|
173
|
+
await assertClean(transaction.repoRoot);
|
|
174
|
+
return { status: "restored", taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, artifactDir, changedFiles: changes };
|
|
175
|
+
}
|
|
176
|
+
export function transactionRecordPath(repoRoot, featureId) {
|
|
177
|
+
return path.join(getTaskPoolRoot(repoRoot), "artifacts", "features", featureId, "git", "transaction.json");
|
|
178
|
+
}
|
|
179
|
+
async function captureFailureArtifacts(repoRoot, artifactDir, changes, ignoredFiles, outcome) {
|
|
180
|
+
await mkdir(path.join(artifactDir, "untracked"), { recursive: true });
|
|
181
|
+
const patchText = await gitRaw(repoRoot, ["diff", "--binary", "HEAD"]);
|
|
182
|
+
await writeFile(path.join(artifactDir, "changes.patch"), patchText, "utf-8");
|
|
183
|
+
const untracked = (await gitRaw(repoRoot, ["ls-files", "--others", "--exclude-standard"])).split(/\r?\n/).filter(Boolean);
|
|
184
|
+
for (const relative of untracked) {
|
|
185
|
+
const source = path.join(repoRoot, relative);
|
|
186
|
+
const target = path.join(artifactDir, "untracked", relative);
|
|
187
|
+
if ((await stat(source)).isFile()) {
|
|
188
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
189
|
+
await cp(source, target);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
await writeJsonAtomic(path.join(artifactDir, "failure.json"), {
|
|
193
|
+
schemaVersion: 1,
|
|
194
|
+
featureId: outcome.taskSpec.feature_id,
|
|
195
|
+
taskId: outcome.taskSpec.id,
|
|
196
|
+
workerRunId: outcome.workerRunId,
|
|
197
|
+
changedFiles: changes,
|
|
198
|
+
untrackedFiles: untracked,
|
|
199
|
+
ignoredFiles,
|
|
200
|
+
ignoredSensitiveFiles: ignoredFiles.filter(isSensitivePath),
|
|
201
|
+
writeBoundaryAudit: auditChangedPathsReport([...changes, ...ignoredFiles], outcome.taskSpec),
|
|
202
|
+
lastCheckpoint: await git(repoRoot, ["rev-parse", "HEAD"]),
|
|
203
|
+
...(outcome.runRecordPath ? { runRecordPath: outcome.runRecordPath } : {}),
|
|
204
|
+
capturedAt: (outcome.now ?? new Date()).toISOString(),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
function auditChangedPaths(changes, taskSpec) {
|
|
208
|
+
const audit = auditChangedPathsReport(changes, taskSpec);
|
|
209
|
+
if (audit.violations.length > 0)
|
|
210
|
+
throw new Error(audit.violations[0]);
|
|
211
|
+
}
|
|
212
|
+
function auditChangedPathsReport(changes, taskSpec) {
|
|
213
|
+
const violations = [];
|
|
214
|
+
for (const changed of changes) {
|
|
215
|
+
if (/(^|\/)(\.env(?:\.|$)|[^/]*\.(?:pem|key|p12|pfx))$/i.test(changed))
|
|
216
|
+
violations.push(`changed path may contain sensitive material: ${changed}`);
|
|
217
|
+
else if (taskSpec.constraints.forbidden_paths.some((glob) => matchesGlob(changed, glob)))
|
|
218
|
+
violations.push(`changed path is forbidden for ${taskSpec.id}: ${changed}`);
|
|
219
|
+
else if (!taskSpec.constraints.allowed_paths.some((glob) => matchesGlob(changed, glob)))
|
|
220
|
+
violations.push(`changed path is outside allowed_paths for ${taskSpec.id}: ${changed}`);
|
|
221
|
+
}
|
|
222
|
+
return { ok: violations.length === 0, violations };
|
|
223
|
+
}
|
|
224
|
+
function matchesGlob(filePath, glob) {
|
|
225
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
226
|
+
const normalizedGlob = glob.replace(/\\/g, "/");
|
|
227
|
+
let pattern = "^";
|
|
228
|
+
for (let index = 0; index < normalizedGlob.length; index += 1) {
|
|
229
|
+
const char = normalizedGlob[index];
|
|
230
|
+
if (char === "*" && normalizedGlob[index + 1] === "*") {
|
|
231
|
+
pattern += ".*";
|
|
232
|
+
index += 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (char === "*") {
|
|
236
|
+
pattern += "[^/]*";
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
pattern += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
240
|
+
}
|
|
241
|
+
return new RegExp(`${pattern}$`).test(normalizedPath);
|
|
242
|
+
}
|
|
243
|
+
function commitMessage(taskSpec, workerRunId) {
|
|
244
|
+
const type = taskSpec.type === "bugfix" || taskSpec.type === "fix-from-failure" ? "fix" : taskSpec.type.startsWith("qa-") ? "test" : "feat";
|
|
245
|
+
return `${type}(${taskSpec.id.toLowerCase()}): ${taskSpec.title}\n\nFeature: ${taskSpec.feature_id}\nTask: ${taskSpec.id}\nAcceptance: ${taskSpec.acceptance_refs.join(", ")}\nAgent-Run: ${workerRunId}\nWorker-Run: ${workerRunId}`;
|
|
246
|
+
}
|
|
247
|
+
function assertSafeBranch(branch) {
|
|
248
|
+
if (!/^agent\/[a-z0-9][a-z0-9._-]*$/.test(branch) || branch.includes("..") || branch.endsWith("."))
|
|
249
|
+
throw new Error(`unsafe Feature branch name: ${branch}`);
|
|
250
|
+
}
|
|
251
|
+
async function assertClean(repoRoot) {
|
|
252
|
+
const statusText = await gitRaw(repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
253
|
+
if (statusText.trim())
|
|
254
|
+
throw new Error(`Git worktree is not clean:\n${statusText.trim()}`);
|
|
255
|
+
}
|
|
256
|
+
async function assertTransactionPosition(repoRoot, record) {
|
|
257
|
+
const branch = await git(repoRoot, ["branch", "--show-current"]);
|
|
258
|
+
if (branch !== record.branch)
|
|
259
|
+
throw new Error(`current branch is outside the Feature transaction: expected ${record.branch}, got ${branch || "detached HEAD"}`);
|
|
260
|
+
const head = await git(repoRoot, ["rev-parse", "HEAD"]);
|
|
261
|
+
if (head !== record.lastCheckpoint)
|
|
262
|
+
throw new Error(`Feature branch HEAD does not match recorded checkpoint: expected ${record.lastCheckpoint}, got ${head}`);
|
|
263
|
+
}
|
|
264
|
+
async function assertCheckpointMetadata(repoRoot, commit, taskSpec, workerRunId) {
|
|
265
|
+
const message = await gitRaw(repoRoot, ["show", "-s", "--format=%B", commit]);
|
|
266
|
+
for (const trailer of [`Feature: ${taskSpec.feature_id}`, `Task: ${taskSpec.id}`, `Acceptance: ${taskSpec.acceptance_refs.join(", ")}`, `Worker-Run: ${workerRunId}`]) {
|
|
267
|
+
if (!message.split(/\r?\n/).includes(trailer))
|
|
268
|
+
throw new Error(`recorded checkpoint metadata mismatch: ${trailer}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function assertIgnoredBaselineUnchanged(baseline, current) {
|
|
272
|
+
const baselineByPath = new Map(baseline.map((entry) => [entry.path, entry.sha256]));
|
|
273
|
+
const changed = current.filter((entry) => baselineByPath.has(entry.path) && baselineByPath.get(entry.path) !== entry.sha256).map((entry) => entry.path);
|
|
274
|
+
const missing = baseline.filter((entry) => !current.some((candidate) => candidate.path === entry.path)).map((entry) => entry.path);
|
|
275
|
+
const added = current.filter((entry) => !baselineByPath.has(entry.path)).map((entry) => entry.path);
|
|
276
|
+
if (changed.length > 0 || missing.length > 0 || added.length > 0)
|
|
277
|
+
throw new Error(`ignored files changed during Git checkpoint: ${[...changed, ...missing, ...added].join(", ")}`);
|
|
278
|
+
}
|
|
279
|
+
async function readChanges(repoRoot) {
|
|
280
|
+
const raw = await gitRaw(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
281
|
+
const fields = raw.split("\0");
|
|
282
|
+
const changes = new Set();
|
|
283
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
284
|
+
const field = fields[index];
|
|
285
|
+
if (!field)
|
|
286
|
+
continue;
|
|
287
|
+
const status = field.slice(0, 2);
|
|
288
|
+
changes.add(field.slice(3).replace(/\\/g, "/"));
|
|
289
|
+
if (/[RC]/.test(status)) {
|
|
290
|
+
const source = fields[index + 1];
|
|
291
|
+
if (source)
|
|
292
|
+
changes.add(source.replace(/\\/g, "/"));
|
|
293
|
+
index += 1;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return [...changes];
|
|
297
|
+
}
|
|
298
|
+
async function listIgnoredFiles(repoRoot) {
|
|
299
|
+
const raw = await gitRaw(repoRoot, ["ls-files", "-z", "--others", "--ignored", "--exclude-standard"]);
|
|
300
|
+
return raw.split("\0").filter((entry) => entry && !entry.startsWith(".harness/")).sort();
|
|
301
|
+
}
|
|
302
|
+
async function readIgnoredBaseline(repoRoot) {
|
|
303
|
+
const paths = await listIgnoredFiles(repoRoot);
|
|
304
|
+
return Promise.all(paths.map(async (relative) => {
|
|
305
|
+
const absolute = path.join(repoRoot, relative);
|
|
306
|
+
const info = await lstat(absolute);
|
|
307
|
+
const content = info.isSymbolicLink() ? `symlink:${await readlink(absolute)}` : await readFile(absolute);
|
|
308
|
+
return { path: relative, sha256: createHash("sha256").update(content).digest("hex") };
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
function isSensitivePath(relative) {
|
|
312
|
+
return /(^|\/)(\.env(?:\.|$)|[^/]*\.(?:pem|key|p12|pfx))$/i.test(relative);
|
|
313
|
+
}
|
|
314
|
+
async function readOptionalRecord(filePath) {
|
|
315
|
+
try {
|
|
316
|
+
return gitTransactionRecordSchema.parse(JSON.parse(await readFile(filePath, "utf-8")));
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (isNotFound(error))
|
|
320
|
+
return undefined;
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function writeJsonAtomic(filePath, value) {
|
|
325
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
326
|
+
const temp = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${randomUUID()}.tmp`);
|
|
327
|
+
try {
|
|
328
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
|
329
|
+
await rename(temp, filePath);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
await unlink(temp).catch(() => { });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function git(repoRoot, args) {
|
|
336
|
+
const result = await execFileAsync("git", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
337
|
+
return result.stdout.trim();
|
|
338
|
+
}
|
|
339
|
+
async function gitRaw(repoRoot, args) {
|
|
340
|
+
const result = await execFileAsync("git", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
341
|
+
return result.stdout;
|
|
342
|
+
}
|
|
343
|
+
async function gitOk(repoRoot, args) {
|
|
344
|
+
try {
|
|
345
|
+
await git(repoRoot, args);
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function isNotFound(error) {
|
|
353
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
354
|
+
}
|