opencode-feature-factory 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +877 -0
- package/assets/agent/backend-builder.md +73 -0
- package/assets/agent/codebase-researcher.md +56 -0
- package/assets/agent/design-interpreter.md +37 -0
- package/assets/agent/frontend-builder.md +74 -0
- package/assets/agent/implementation-validator.md +73 -0
- package/assets/agent/security-reviewer.md +60 -0
- package/assets/agent/spec-writer.md +94 -0
- package/assets/agent/story-reader.md +38 -0
- package/assets/agent/story-writer.md +39 -0
- package/assets/agent/test-verifier.md +73 -0
- package/assets/agent/work-decomposer.md +102 -0
- package/assets/agent/work-reviewer.md +77 -0
- package/assets/command/feature.md +68 -0
- package/assets/skills/feature/SCHEMA.md +990 -0
- package/assets/skills/feature/SKILL.md +620 -0
- package/dist/tui.js +3641 -0
- package/package.json +65 -0
- package/src/cleanup-sweep-command.js +75 -0
- package/src/cleanup-sweep-eligibility.js +581 -0
- package/src/cleanup-sweep-output.js +139 -0
- package/src/cleanup-sweep-report.js +548 -0
- package/src/cleanup-sweep.js +546 -0
- package/src/cli-output.js +251 -0
- package/src/cli.js +1152 -0
- package/src/config.js +231 -0
- package/src/cost-attribution.js +327 -0
- package/src/cost-report.js +185 -0
- package/src/detached-log-supervisor.js +178 -0
- package/src/doctor.js +598 -0
- package/src/env-snapshot.js +211 -0
- package/src/factory-diagnostics.js +429 -0
- package/src/factory-paths.js +40 -0
- package/src/factory.js +4769 -0
- package/src/feature-command-payload.js +378 -0
- package/src/git.js +110 -0
- package/src/github.js +252 -0
- package/src/hardening/atomic-write.js +954 -0
- package/src/hardening/line-output.js +139 -0
- package/src/hardening/output-policy.js +365 -0
- package/src/hardening/process-verification.js +542 -0
- package/src/hardening/sensitive-data.js +341 -0
- package/src/hardening/terminal-encoding.js +224 -0
- package/src/plugin.js +246 -0
- package/src/post-pr-ci.js +754 -0
- package/src/process-evidence.js +1139 -0
- package/src/refs.js +144 -0
- package/src/run-state.js +2411 -0
- package/src/steering-conflicts.js +77 -0
- package/src/telemetry.js +419 -0
- package/src/tui-data.js +499 -0
- package/src/tui-rendering.js +148 -0
- package/src/utils.js +35 -0
- package/src/validate.js +1655 -0
- package/src/worktrees.js +56 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { cleanupRunLocked, CleanupRunChangedError, CleanupRunUnexpectedError } from "./factory.js";
|
|
5
|
+
import { git } from "./git.js";
|
|
6
|
+
import { github } from "./github.js";
|
|
7
|
+
import { classifyCleanupSweepCandidate, discoverCleanupSweepCandidates } from "./cleanup-sweep-eligibility.js";
|
|
8
|
+
import { createCleanupSweepConfirmation } from "./cleanup-sweep-output.js";
|
|
9
|
+
import {
|
|
10
|
+
canonicalJson,
|
|
11
|
+
compareCleanupSweepDigest,
|
|
12
|
+
createCandidate,
|
|
13
|
+
createCleanupSweepDigest,
|
|
14
|
+
createCleanupSweepReport,
|
|
15
|
+
createRefusedReport,
|
|
16
|
+
createReportLevelFailure,
|
|
17
|
+
createRepositoryIdentity,
|
|
18
|
+
parseCleanupSweepDigest,
|
|
19
|
+
} from "./cleanup-sweep-report.js";
|
|
20
|
+
import { RunJsonLockContendedError, withRunJsonLock } from "./run-state.js";
|
|
21
|
+
import { acquireLaunchFence, releaseLaunchFence } from "./process-evidence.js";
|
|
22
|
+
import { canonicalizeGithubPrUrl } from "./refs.js";
|
|
23
|
+
import { validateRun } from "./validate.js";
|
|
24
|
+
|
|
25
|
+
const utf8 = (left, right) => Buffer.from(left, "utf8").compare(Buffer.from(right, "utf8"));
|
|
26
|
+
|
|
27
|
+
export async function previewCleanupSweep(options = {}) {
|
|
28
|
+
let repository = null;
|
|
29
|
+
let candidates = [];
|
|
30
|
+
let temporaryRefs = [];
|
|
31
|
+
let discoveryComplete = false;
|
|
32
|
+
let report;
|
|
33
|
+
try {
|
|
34
|
+
const invocationId = invocationIdentifier(options);
|
|
35
|
+
repository = resolveCleanupSweepRepository(options);
|
|
36
|
+
const tracked = trackedDiscoveryOptions(options, invocationId, temporaryRefs);
|
|
37
|
+
const discovery = discoverCleanupSweepCandidates(repository.root_path, tracked.options);
|
|
38
|
+
discoveryComplete = true;
|
|
39
|
+
candidates = discovery.candidates;
|
|
40
|
+
temporaryRefs.push(...temporaryRefRecords(discovery.temporary_refs, candidates, repository, tracked.prBaseOids));
|
|
41
|
+
const digest = createCleanupSweepDigest(repository, candidates);
|
|
42
|
+
report = createCleanupSweepReport({
|
|
43
|
+
mode: "preview",
|
|
44
|
+
status: "previewed",
|
|
45
|
+
repository,
|
|
46
|
+
authorization: { schema_version: 1, digest, provided_digest: null, matched: null, refusal_code: null },
|
|
47
|
+
candidates,
|
|
48
|
+
report_errors: [],
|
|
49
|
+
confirmation: createCleanupSweepConfirmation(digest, repository.root_path, { json: options.json === true }),
|
|
50
|
+
exit_code: 0,
|
|
51
|
+
});
|
|
52
|
+
} catch {
|
|
53
|
+
report = createReportLevelFailure({
|
|
54
|
+
mode: "preview",
|
|
55
|
+
code: discoveryComplete ? "FAILED_ORCHESTRATION" : "FAILED_FACTORY_ROOT",
|
|
56
|
+
repository,
|
|
57
|
+
candidates,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return finalizeAfterTemporaryRefs(report, { ...options, repository, candidates, temporaryRefs, mode: "preview" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function executeCleanupSweep(options = {}) {
|
|
64
|
+
parseCleanupSweepDigest(options.digest);
|
|
65
|
+
let repository = null;
|
|
66
|
+
let candidates = [];
|
|
67
|
+
let temporaryRefs = [];
|
|
68
|
+
let report;
|
|
69
|
+
let discoveryComplete = false;
|
|
70
|
+
try {
|
|
71
|
+
const invocationId = invocationIdentifier(options);
|
|
72
|
+
repository = resolveCleanupSweepRepository(options);
|
|
73
|
+
const tracked = trackedDiscoveryOptions(options, invocationId, temporaryRefs);
|
|
74
|
+
const discovery = discoverCleanupSweepCandidates(repository.root_path, tracked.options);
|
|
75
|
+
discoveryComplete = true;
|
|
76
|
+
candidates = discovery.candidates;
|
|
77
|
+
temporaryRefs.push(...temporaryRefRecords(discovery.temporary_refs, candidates, repository, tracked.prBaseOids));
|
|
78
|
+
const comparison = compareCleanupSweepDigest(options.digest, repository, candidates);
|
|
79
|
+
await phase(options, "after-digest-recompute", { repository, candidates });
|
|
80
|
+
if (!comparison.matched) {
|
|
81
|
+
report = createRefusedReport({
|
|
82
|
+
repository,
|
|
83
|
+
candidates,
|
|
84
|
+
provided_digest: options.digest,
|
|
85
|
+
refusal_code: comparison.refusal_code,
|
|
86
|
+
digest: comparison.digest,
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
90
|
+
const candidate = candidates[index];
|
|
91
|
+
if (candidate.classification !== "eligible") {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
candidates[index] = await executeCandidate(repository, candidate, options, invocationId, temporaryRefs);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error?.completedCandidate) candidates[index] = error.completedCandidate;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
report = createCleanupSweepReport({
|
|
102
|
+
mode: "execute",
|
|
103
|
+
status: "completed",
|
|
104
|
+
repository,
|
|
105
|
+
authorization: { schema_version: 1, digest: options.digest, provided_digest: options.digest, matched: true, refusal_code: null },
|
|
106
|
+
candidates,
|
|
107
|
+
report_errors: [],
|
|
108
|
+
confirmation: { argv: null, shell_command: null },
|
|
109
|
+
exit_code: candidates.some((candidate) => candidate.attempted_cleanup && candidate.classification === "failed") ? 1 : 0,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
report = createReportLevelFailure({
|
|
114
|
+
mode: "execute",
|
|
115
|
+
code: discoveryComplete ? "FAILED_ORCHESTRATION" : "FAILED_FACTORY_ROOT",
|
|
116
|
+
repository,
|
|
117
|
+
candidates,
|
|
118
|
+
provided_digest: options.digest,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return finalizeAfterTemporaryRefs(report, { ...options, repository, candidates, temporaryRefs, mode: "execute", providedDigest: options.digest });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveCleanupSweepRepository(options = {}) {
|
|
125
|
+
const gitRunner = options.gitRunner ?? git;
|
|
126
|
+
const selected = resolve(options.cwd ?? process.cwd());
|
|
127
|
+
const rootResult = gitRunner(selected, ["rev-parse", "--show-toplevel"]);
|
|
128
|
+
if (!rootResult?.ok || !rootResult.stdout?.trim()) throw new Error("repository root is unavailable");
|
|
129
|
+
const rootPath = inspectPath(rootResult.stdout.trim(), "realpath", options);
|
|
130
|
+
const commonResult = gitRunner(rootPath, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
131
|
+
const objectResult = gitRunner(rootPath, ["rev-parse", "--show-object-format"]);
|
|
132
|
+
if (!commonResult?.ok || !commonResult.stdout?.trim() || !objectResult?.ok) throw new Error("repository identity is unavailable");
|
|
133
|
+
const commonPath = inspectPath(resolve(rootPath, commonResult.stdout.trim()), "realpath", options);
|
|
134
|
+
const rootStat = inspectPath(rootPath, "stat", options);
|
|
135
|
+
const commonStat = inspectPath(commonPath, "stat", options);
|
|
136
|
+
if (!rootStat.isDirectory() || !commonStat.isDirectory()) throw new Error("repository controls are not directories");
|
|
137
|
+
return createRepositoryIdentity({
|
|
138
|
+
schema_version: 1,
|
|
139
|
+
root_path: rootPath,
|
|
140
|
+
root_device: String(rootStat.dev),
|
|
141
|
+
root_inode: String(rootStat.ino),
|
|
142
|
+
git_common_dir_path: commonPath,
|
|
143
|
+
git_common_dir_device: String(commonStat.dev),
|
|
144
|
+
git_common_dir_inode: String(commonStat.ino),
|
|
145
|
+
object_format: objectResult.stdout.trim(),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function executeCandidate(repository, authorized, options, invocationId, temporaryRefs) {
|
|
150
|
+
const runDir = join(repository.root_path, ".opencode", "factory", authorized.entry_name);
|
|
151
|
+
const acquire = options.acquireRunLock ?? ((path, callback, lockOptions) => withRunJsonLock(path, callback, lockOptions));
|
|
152
|
+
let completedCandidate = null;
|
|
153
|
+
const rawGitRunner = options.gitRunner ?? git;
|
|
154
|
+
const mutationGitRunner = repositoryGuardedGitRunner(repository, options, rawGitRunner);
|
|
155
|
+
const guardedOptions = { ...options, gitRunner: mutationGitRunner };
|
|
156
|
+
try {
|
|
157
|
+
return await acquire(runDir, async () => {
|
|
158
|
+
await phase(options, "after-candidate-lock", { entry_name: authorized.entry_name });
|
|
159
|
+
let reclassified;
|
|
160
|
+
try {
|
|
161
|
+
reclassified = reclassifyHeldCandidate(repository, authorized, guardedOptions, invocationId, temporaryRefs);
|
|
162
|
+
} catch {
|
|
163
|
+
return inspectionFailureCandidate(authorized);
|
|
164
|
+
}
|
|
165
|
+
let normalized = normalizeHeldLockEvidence(reclassified.candidate);
|
|
166
|
+
if (authorizationRecord(normalized) !== authorizationRecord(authorized)) return changedCandidate(normalized);
|
|
167
|
+
await phase(options, "after-candidate-revalidation", { entry_name: authorized.entry_name, candidate: normalized });
|
|
168
|
+
|
|
169
|
+
let launchFence;
|
|
170
|
+
try {
|
|
171
|
+
const acquireFence = options.acquireLaunchFence ?? acquireLaunchFence;
|
|
172
|
+
launchFence = acquireFence(runDir, "cleanup", options.processOptions || options);
|
|
173
|
+
} catch {
|
|
174
|
+
return inspectionFailureCandidate(normalized);
|
|
175
|
+
}
|
|
176
|
+
if (!launchFence?.acquired) return changedCandidate(normalized);
|
|
177
|
+
try {
|
|
178
|
+
// Fence acquisition closes the launch race. Recompute every
|
|
179
|
+
// authorization field while holding it, then retain it through all
|
|
180
|
+
// destructive cleanup and restoration work.
|
|
181
|
+
try {
|
|
182
|
+
reclassified = reclassifyHeldCandidate(repository, authorized, guardedOptions, invocationId, temporaryRefs);
|
|
183
|
+
} catch {
|
|
184
|
+
return inspectionFailureCandidate(normalized);
|
|
185
|
+
}
|
|
186
|
+
normalized = normalizeHeldLockEvidence(reclassified.candidate);
|
|
187
|
+
if (authorizationRecord(normalized) !== authorizationRecord(authorized)) return changedCandidate(normalized);
|
|
188
|
+
|
|
189
|
+
const baseRef = reclassified.temporary_ref;
|
|
190
|
+
const baseOid = authorized.evidence.pr.base_sha;
|
|
191
|
+
const baseState = inspectAuthorizedTemporaryRef(repository.root_path, baseRef, baseOid, mutationGitRunner);
|
|
192
|
+
if (baseState === "changed") return changedCandidate(normalized);
|
|
193
|
+
if (baseState !== "matching") return inspectionFailureCandidate(normalized);
|
|
194
|
+
|
|
195
|
+
let run;
|
|
196
|
+
try {
|
|
197
|
+
run = validateRun(JSON.parse(readFileSync(join(runDir, "run.json"), "utf8")));
|
|
198
|
+
} catch {
|
|
199
|
+
return changedCandidate(normalized);
|
|
200
|
+
}
|
|
201
|
+
const branches = normalized.evidence.branches.filter((item) => item.state === "verified-ancestor");
|
|
202
|
+
let cleanup;
|
|
203
|
+
try {
|
|
204
|
+
cleanup = cleanupRunLocked(runDir, run, {
|
|
205
|
+
mode: "cleanup-sweep",
|
|
206
|
+
repo: repository.root_path,
|
|
207
|
+
force: false,
|
|
208
|
+
dryRun: false,
|
|
209
|
+
expectedRunHash: normalized.evidence.run.hash,
|
|
210
|
+
expectedBranchHeads: branches,
|
|
211
|
+
expectedRunDirectory: normalized.evidence.entry,
|
|
212
|
+
expectedWorktreeRoot: normalized.evidence.worktree_root,
|
|
213
|
+
expectedWorktrees: normalized.evidence.worktrees,
|
|
214
|
+
fetchedBaseRef: baseOid,
|
|
215
|
+
fetchedBase: { ref: baseRef, oid: baseOid },
|
|
216
|
+
gitRunner: guardedCleanupGitRunner(repository.root_path, baseRef, baseOid, mutationGitRunner),
|
|
217
|
+
assertMutationAuthorized: () => assertCandidateMutationAuthorized(repository, authorized, guardedOptions, invocationId, temporaryRefs),
|
|
218
|
+
removeRunDir: options.removeRunDir,
|
|
219
|
+
checkWorktreeIdentity: options.checkWorktreeIdentity,
|
|
220
|
+
physicalPath: options.physicalPath,
|
|
221
|
+
phaseHook: options.phaseHook,
|
|
222
|
+
});
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error instanceof CleanupRunChangedError || error?.code === "CLEANUP_EVIDENCE_CHANGED") return changedCandidate(normalized);
|
|
225
|
+
if (error instanceof CleanupRunUnexpectedError || error?.code === "FAILED_CLEANUP_UNEXPECTED") {
|
|
226
|
+
completedCandidate = unexpectedCleanupCandidate(normalized, error.cleanup, runDir);
|
|
227
|
+
return completedCandidate;
|
|
228
|
+
}
|
|
229
|
+
return inspectionFailureCandidate(normalized);
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
completedCandidate = candidateFromCleanup(normalized, cleanup);
|
|
233
|
+
} catch {
|
|
234
|
+
completedCandidate = unexpectedCleanupCandidate(normalized, cleanup, runDir);
|
|
235
|
+
}
|
|
236
|
+
return completedCandidate;
|
|
237
|
+
} finally {
|
|
238
|
+
const releaseFenceFn = options.releaseLaunchFence ?? releaseLaunchFence;
|
|
239
|
+
if (!releaseFenceFn(launchFence)) throw new Error("cleanup launch fence release failed");
|
|
240
|
+
}
|
|
241
|
+
}, { reclaimMode: "never" });
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (completedCandidate) {
|
|
244
|
+
const orchestrationError = new Error("candidate lock orchestration failed after cleanup", { cause: error });
|
|
245
|
+
orchestrationError.completedCandidate = completedCandidate;
|
|
246
|
+
throw orchestrationError;
|
|
247
|
+
}
|
|
248
|
+
if (error instanceof RunJsonLockContendedError || error?.code === "RUN_JSON_LOCK_CONTENDED") {
|
|
249
|
+
const evidence = structuredClone(authorized.evidence);
|
|
250
|
+
evidence.run_lock = { observed_before_acquire: "present", held_by_sweep: false };
|
|
251
|
+
return replacementCandidate({ ...authorized, evidence }, "skipped", ["SKIPPED_RUN_LOCK_CONTENDED"]);
|
|
252
|
+
}
|
|
253
|
+
return replacementCandidate(authorized, "failed", ["FAILED_INSPECTION"], { failure_stage: "inspection" });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function candidateFromCleanup(candidate, cleanup) {
|
|
258
|
+
normalizePartialCleanup(cleanup, candidate);
|
|
259
|
+
const reasonCodes = [];
|
|
260
|
+
if (cleanup.worktrees.some((item) => item.outcome === "failed")) reasonCodes.push("FAILED_CLEANUP_WORKTREE");
|
|
261
|
+
if (cleanup.branches.some((item) => item.outcome === "failed")) reasonCodes.push("FAILED_CLEANUP_BRANCH");
|
|
262
|
+
if (cleanup.run_dir.outcome === "failed") reasonCodes.push("FAILED_CLEANUP_RUN_DIR");
|
|
263
|
+
if (cleanup.run_dir.outcome === "retained") reasonCodes.push("RETAINED_AFTER_PARTIAL_FAILURE");
|
|
264
|
+
const success = reasonCodes.length === 0;
|
|
265
|
+
return replacementCandidate(candidate, success ? "deleted" : "failed", success ? ["DELETED"] : reasonCodes, {
|
|
266
|
+
failure_stage: success ? null : "cleanup",
|
|
267
|
+
attempted_cleanup: true,
|
|
268
|
+
cleanup,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function unexpectedCleanupCandidate(candidate, cleanup, runDir) {
|
|
273
|
+
const value = cleanup ?? { worktrees: [], branches: [], run_dir: { path: runDir, outcome: "retained", reason_code: "FAILED_CLEANUP_UNEXPECTED" } };
|
|
274
|
+
const hasTargetFailure = value.worktrees.some((item) => item.outcome === "failed") || value.branches.some((item) => item.outcome !== "deleted");
|
|
275
|
+
value.run_dir = { path: value.run_dir?.path ?? runDir, outcome: "retained", reason_code: hasTargetFailure ? "RETAINED_AFTER_PARTIAL_FAILURE" : "FAILED_CLEANUP_UNEXPECTED" };
|
|
276
|
+
normalizePartialCleanup(value, candidate);
|
|
277
|
+
return replacementCandidate(candidate, "failed", ["FAILED_CLEANUP_UNEXPECTED"], {
|
|
278
|
+
failure_stage: "cleanup", attempted_cleanup: true, cleanup: value,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function normalizePartialCleanup(cleanup, candidate) {
|
|
283
|
+
for (const worktree of cleanup.worktrees) {
|
|
284
|
+
if (worktree.branch !== null) continue;
|
|
285
|
+
const authorized = candidate.evidence.worktrees.find((item) => item.physical_path === worktree.physical_path && item.recorded_path === worktree.recorded_path);
|
|
286
|
+
if (authorized?.branch) worktree.branch = authorized.branch;
|
|
287
|
+
}
|
|
288
|
+
const failedBranches = new Set(cleanup.worktrees.filter((item) => item.outcome === "failed" && item.branch).map((item) => item.branch));
|
|
289
|
+
for (const branch of cleanup.branches) {
|
|
290
|
+
if (branch.outcome === "not-attempted" && failedBranches.has(branch.name)) branch.reason_code = "RETAINED_AFTER_PARTIAL_FAILURE";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function changedCandidate(candidate) {
|
|
295
|
+
return replacementCandidate(candidate, "skipped", ["SKIPPED_CHANGED_DURING_EXECUTION"]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function inspectionFailureCandidate(candidate) {
|
|
299
|
+
return replacementCandidate(candidate, "failed", ["FAILED_INSPECTION"], { failure_stage: "inspection" });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function replacementCandidate(candidate, classification, reasonCodes, overrides = {}) {
|
|
303
|
+
return createCandidate({
|
|
304
|
+
entry_name: candidate.entry_name,
|
|
305
|
+
run_id: candidate.run_id,
|
|
306
|
+
classification,
|
|
307
|
+
reason_codes: reasonCodes,
|
|
308
|
+
evidence: candidate.evidence,
|
|
309
|
+
failure_stage: overrides.failure_stage ?? null,
|
|
310
|
+
attempted_cleanup: overrides.attempted_cleanup ?? false,
|
|
311
|
+
cleanup: overrides.cleanup ?? null,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function normalizeHeldLockEvidence(candidate) {
|
|
316
|
+
const evidence = structuredClone(candidate.evidence);
|
|
317
|
+
evidence.run_lock = { observed_before_acquire: "missing", held_by_sweep: false };
|
|
318
|
+
return createCandidate({ ...candidate, evidence, evidence_digest: undefined });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function authorizationRecord(candidate) {
|
|
322
|
+
return canonicalJson({
|
|
323
|
+
entry_name: candidate.entry_name,
|
|
324
|
+
run_id: candidate.run_id,
|
|
325
|
+
classification: candidate.classification,
|
|
326
|
+
reason_codes: candidate.reason_codes,
|
|
327
|
+
evidence_digest: candidate.evidence_digest,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function discoveryOptions(options, invocationId) {
|
|
332
|
+
return {
|
|
333
|
+
githubRunner: options.githubRunner,
|
|
334
|
+
gitRunner: options.gitRunner,
|
|
335
|
+
fsInspector: options.fsInspector,
|
|
336
|
+
inspectProcess: options.inspectProcess,
|
|
337
|
+
processOptions: { ...options, ...options.processOptions },
|
|
338
|
+
clock: options.clock,
|
|
339
|
+
invocationId,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function temporaryRefRecords(refs, candidates, repository, prBaseOids) {
|
|
344
|
+
return refs.map((ref) => {
|
|
345
|
+
const candidate = candidates.find((item) => ref.endsWith(sha256RunId(item.entry_name)));
|
|
346
|
+
const expectedOid = candidate?.evidence?.pr?.base_sha ?? recordedRunBaseOid(repository.root_path, candidate?.entry_name, prBaseOids);
|
|
347
|
+
return { ref, expected_oid: expectedOid ?? null };
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function trackedDiscoveryOptions(options, invocationId, temporaryRefs) {
|
|
352
|
+
const githubRunner = options.githubRunner ?? github;
|
|
353
|
+
const gitRunner = options.gitRunner ?? git;
|
|
354
|
+
const prBaseOids = new Map();
|
|
355
|
+
let currentBaseOid = null;
|
|
356
|
+
const trackedGithubRunner = (...args) => {
|
|
357
|
+
currentBaseOid = null;
|
|
358
|
+
const result = githubRunner(...args);
|
|
359
|
+
try {
|
|
360
|
+
const body = result?.ok ? JSON.parse(result.stdout) : null;
|
|
361
|
+
if (typeof body?.html_url === "string" && typeof body?.base?.sha === "string") {
|
|
362
|
+
currentBaseOid = body.base.sha;
|
|
363
|
+
prBaseOids.set(canonicalizeGithubPrUrl(body.html_url), currentBaseOid);
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// Invalid responses cannot authorize temporary-ref deletion.
|
|
367
|
+
}
|
|
368
|
+
return result;
|
|
369
|
+
};
|
|
370
|
+
const trackedGitRunner = (cwd, args, commandOptions) => {
|
|
371
|
+
if (args[0] === "check-ref-format" && typeof args[1] === "string" && args[1].startsWith("refs/feature-factory/cleanup-sweep/")) {
|
|
372
|
+
temporaryRefs.push({ ref: args[1], expected_oid: currentBaseOid });
|
|
373
|
+
}
|
|
374
|
+
return gitRunner(cwd, args, commandOptions);
|
|
375
|
+
};
|
|
376
|
+
return {
|
|
377
|
+
options: discoveryOptions({ ...options, githubRunner: trackedGithubRunner, gitRunner: trackedGitRunner }, invocationId),
|
|
378
|
+
prBaseOids,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function recordedRunBaseOid(repo, entryName, prBaseOids) {
|
|
383
|
+
if (!entryName) return null;
|
|
384
|
+
try {
|
|
385
|
+
const run = JSON.parse(readFileSync(join(repo, ".opencode", "factory", entryName, "run.json"), "utf8"));
|
|
386
|
+
const urls = [run?.pr_url, run?.terminal_result?.pr_url].filter((value) => typeof value === "string");
|
|
387
|
+
for (const url of urls) {
|
|
388
|
+
const canonical = canonicalizeGithubPrUrl(url);
|
|
389
|
+
if (prBaseOids.has(canonical)) return prBaseOids.get(canonical);
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
// Missing authorization is handled as FAILED_TEMP_REF_CLEANUP if the ref exists.
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function sha256RunId(value) {
|
|
398
|
+
return createHash("sha256").update(value).digest("hex");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function finalizeAfterTemporaryRefs(report, context) {
|
|
402
|
+
try {
|
|
403
|
+
await cleanupTemporaryRefs(context.temporaryRefs, context);
|
|
404
|
+
return report;
|
|
405
|
+
} catch {
|
|
406
|
+
return createReportLevelFailure({
|
|
407
|
+
mode: context.mode,
|
|
408
|
+
code: "FAILED_TEMP_REF_CLEANUP",
|
|
409
|
+
repository: context.repository,
|
|
410
|
+
candidates: context.candidates,
|
|
411
|
+
provided_digest: context.providedDigest ?? null,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function cleanupTemporaryRefs(records, options) {
|
|
417
|
+
const byRef = new Map();
|
|
418
|
+
for (const record of records.filter((item) => item?.ref)) {
|
|
419
|
+
const previous = byRef.get(record.ref);
|
|
420
|
+
byRef.set(record.ref, { ref: record.ref, expected_oid: record.expected_oid ?? previous?.expected_oid ?? null });
|
|
421
|
+
}
|
|
422
|
+
const unique = [...byRef.values()].sort((a, b) => utf8(a.ref, b.ref));
|
|
423
|
+
const rawGitRunner = options.gitRunner ?? git;
|
|
424
|
+
const gitRunner = options.repository ? repositoryGuardedGitRunner(options.repository, options, rawGitRunner) : rawGitRunner;
|
|
425
|
+
let failed = false;
|
|
426
|
+
for (const record of unique) {
|
|
427
|
+
try {
|
|
428
|
+
await phase(options, "before-temp-ref-delete", { ref: record.ref, expected_oid: record.expected_oid });
|
|
429
|
+
const present = gitRunner(options.repository.root_path, ["show-ref", "--verify", "--quiet", record.ref]);
|
|
430
|
+
if (!present?.ok) {
|
|
431
|
+
if (present?.status === 1) continue;
|
|
432
|
+
failed = true;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const current = gitRunner(options.repository.root_path, ["rev-parse", "--verify", `${record.ref}^{commit}`]);
|
|
436
|
+
if (!current?.ok || !record.expected_oid || current.stdout.trim() !== record.expected_oid) {
|
|
437
|
+
failed = true;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
let remove;
|
|
441
|
+
if (options.deleteTemporaryRef) {
|
|
442
|
+
assertRepositoryIdentity(options.repository, options, rawGitRunner);
|
|
443
|
+
remove = await options.deleteTemporaryRef(options.repository.root_path, record.ref, record.expected_oid);
|
|
444
|
+
} else {
|
|
445
|
+
remove = gitRunner(options.repository.root_path, ["update-ref", "-d", record.ref, record.expected_oid]);
|
|
446
|
+
}
|
|
447
|
+
if (remove === false || (remove && typeof remove === "object" && remove.ok !== true)) failed = true;
|
|
448
|
+
} catch {
|
|
449
|
+
failed = true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (failed) throw new Error("one or more temporary refs could not be removed safely");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function reclassifyHeldCandidate(repository, authorized, options, invocationId, temporaryRefs) {
|
|
456
|
+
const registerTemporaryRef = (ref) => temporaryRefs.push({ ref, expected_oid: authorized.evidence.pr.base_sha });
|
|
457
|
+
const result = classifyCleanupSweepCandidate(repository.root_path, authorized.entry_name, {
|
|
458
|
+
...discoveryOptions(options, invocationId),
|
|
459
|
+
heldRunId: authorized.run_id,
|
|
460
|
+
registerTemporaryRef,
|
|
461
|
+
});
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function inspectAuthorizedTemporaryRef(repo, ref, expectedOid, gitRunner) {
|
|
466
|
+
if (!ref || !expectedOid) return "uncertain";
|
|
467
|
+
try {
|
|
468
|
+
const resolved = gitRunner(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
|
|
469
|
+
if (!resolved?.ok) return "uncertain";
|
|
470
|
+
return resolved.stdout.trim() === expectedOid ? "matching" : "changed";
|
|
471
|
+
} catch {
|
|
472
|
+
return "uncertain";
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function guardedCleanupGitRunner(repo, baseRef, baseOid, gitRunner) {
|
|
477
|
+
return (cwd, args, commandOptions) => {
|
|
478
|
+
if (args[0] === "merge-base" && args[1] === "--is-ancestor" && args[3] === baseOid) {
|
|
479
|
+
const state = inspectAuthorizedTemporaryRef(repo, baseRef, baseOid, gitRunner);
|
|
480
|
+
if (state !== "matching") return { ok: false, status: 2, stdout: "", stderr: "", command: null, signal: null };
|
|
481
|
+
}
|
|
482
|
+
return gitRunner(cwd, args, commandOptions);
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function assertCandidateMutationAuthorized(repository, authorized, options, invocationId, temporaryRefs) {
|
|
487
|
+
const reclassified = reclassifyHeldCandidate(repository, authorized, options, invocationId, temporaryRefs);
|
|
488
|
+
const normalized = normalizeHeldLockEvidence(reclassified.candidate);
|
|
489
|
+
if (sidecarAuthorizationRecord(normalized) !== sidecarAuthorizationRecord(authorized)) throw new CleanupRunChangedError();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function sidecarAuthorizationRecord(candidate) {
|
|
493
|
+
return canonicalJson({
|
|
494
|
+
entry_name: candidate.entry_name,
|
|
495
|
+
run_id: candidate.run_id,
|
|
496
|
+
entry: candidate.evidence.entry,
|
|
497
|
+
run: candidate.evidence.run,
|
|
498
|
+
factory_lock: candidate.evidence.factory_lock,
|
|
499
|
+
heartbeat: candidate.evidence.heartbeat,
|
|
500
|
+
process: candidate.evidence.process,
|
|
501
|
+
launch_claim: candidate.evidence.launch_claim,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function repositoryGuardedGitRunner(repository, options, gitRunner) {
|
|
506
|
+
return (cwd, args, commandOptions) => {
|
|
507
|
+
if (isCleanupGitMutation(args)) {
|
|
508
|
+
try {
|
|
509
|
+
assertRepositoryIdentity(repository, options, gitRunner);
|
|
510
|
+
} catch {
|
|
511
|
+
return { ok: false, status: 2, stdout: "", stderr: "", command: null, signal: null, cleanupEvidenceChanged: true };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return gitRunner(cwd, args, commandOptions);
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function isCleanupGitMutation(args) {
|
|
519
|
+
return args[0] === "fetch" || args[0] === "update-ref"
|
|
520
|
+
|| args[0] === "worktree" && ["move", "remove"].includes(args[1]);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function assertRepositoryIdentity(expected, options, gitRunner) {
|
|
524
|
+
const current = resolveCleanupSweepRepository({ ...options, cwd: expected.root_path, gitRunner });
|
|
525
|
+
if (canonicalJson(current) !== canonicalJson(expected)) throw new CleanupRunChangedError();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function inspectPath(path, operation, options) {
|
|
529
|
+
const fallback = () => operation === "realpath" ? realpathSync(path) : statSync(path);
|
|
530
|
+
const inspector = options.fsInspector;
|
|
531
|
+
if (typeof inspector === "function") {
|
|
532
|
+
const result = inspector(path, { operation, inspectDefault: fallback });
|
|
533
|
+
return result === undefined ? fallback() : result;
|
|
534
|
+
}
|
|
535
|
+
if (inspector && typeof inspector[operation] === "function") return inspector[operation](path, { inspectDefault: fallback });
|
|
536
|
+
return fallback();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function invocationIdentifier(options) {
|
|
540
|
+
const value = typeof options.invocationId === "function" ? options.invocationId() : options.invocationId;
|
|
541
|
+
return value ?? (typeof options.uuid === "function" ? options.uuid() : randomUUID());
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function phase(options, name, context) {
|
|
545
|
+
if (typeof options.phaseHook === "function") await options.phaseHook(name, context);
|
|
546
|
+
}
|