feature-factory 0.7.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 +278 -0
- package/WORKFLOW.md +2001 -0
- package/agents/backend-builder.md +102 -0
- package/agents/codebase-researcher.md +122 -0
- package/agents/design-interpreter.md +71 -0
- package/agents/frontend-builder.md +110 -0
- package/agents/implementation-validator.md +78 -0
- package/agents/spec-writer.md +95 -0
- package/agents/story-reader.md +70 -0
- package/agents/story-writer.md +62 -0
- package/agents/test-verifier.md +94 -0
- package/agents/work-decomposer.md +188 -0
- package/agents/work-reviewer.md +131 -0
- package/bin/factory.js +1499 -0
- package/bin/init-publication.js +73 -0
- package/core/atomic-write.js +135 -0
- package/core/contracts.js +394 -0
- package/core/effective-push.js +88 -0
- package/core/executable.js +29 -0
- package/core/run-lock.js +269 -0
- package/core/write-core.js +146 -0
- package/observe/index.js +366 -0
- package/observe/repair-record.js +300 -0
- package/observe/repair-reverification.js +169 -0
- package/observe/repository-config.js +56 -0
- package/observe/review.js +362 -0
- package/package.json +35 -0
- package/state/index.js +64 -0
- package/state/review-archive.js +48 -0
- package/state/schema.js +339 -0
- package/state/session-lock.js +104 -0
- package/state/transition.js +26 -0
package/state/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The package's entire public surface for consumers (the opencode plugin, the TUI):
|
|
2
|
+
// a read-only reader plus the schema. Anything that mutates goes through the CLI.
|
|
3
|
+
//
|
|
4
|
+
// Finding 6: `transition` used to be exported from here, and package.json exposes
|
|
5
|
+
// this module as the package root — so the public API handed out mutation authority
|
|
6
|
+
// while claiming to be read-only. It now lives in ./transition.js, which is not
|
|
7
|
+
// exported from package.json and is imported only by bin/factory.js.
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { GATE_NAMES, validateRun } from "./schema.js";
|
|
11
|
+
|
|
12
|
+
export { CONTROL_PLANE, validateRun, SchemaError, RUN_KEYS, SCHEMA_VERSION, RUN_STATUSES, TERMINAL_STATUSES, MODES,
|
|
13
|
+
GATE_NAMES, GATE_STATUSES, STEP_STATUSES, SLICE_STATUSES, VALIDATOR_VERDICTS } from "./schema.js";
|
|
14
|
+
|
|
15
|
+
export function readRun(runDir) {
|
|
16
|
+
return validateRun(JSON.parse(readFileSync(join(runDir, "run.json"), "utf8")));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Read a run without validating, for diagnostics that must describe a broken
|
|
20
|
+
// record rather than refuse to load it. Never use this to make a decision.
|
|
21
|
+
export function readRunUnchecked(runDir) {
|
|
22
|
+
try {
|
|
23
|
+
return { ok: true, run: JSON.parse(readFileSync(join(runDir, "run.json"), "utf8")) };
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return { ok: false, error: error.message };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function nextSliceAction(slices) {
|
|
30
|
+
const blockedSlice = slices.find((slice) => slice.status === "blocked");
|
|
31
|
+
if (blockedSlice) return `blocked-slice:${blockedSlice.id}`;
|
|
32
|
+
const activeSlice = slices.find((slice) => ["running", "review"].includes(slice.status));
|
|
33
|
+
if (activeSlice) return `observe-slice:${activeSlice.id}`;
|
|
34
|
+
const pendingSlice = slices.find((slice) => slice.status === "pending");
|
|
35
|
+
return pendingSlice ? `dispatch-slice:${pendingSlice.id}` : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The single answer to "what happens next". Both `factory status` and the opencode
|
|
39
|
+
// sidebar need it, and two implementations of resume order would drift — which is the
|
|
40
|
+
// defect class this codebase keeps finding. Read-only, so it belongs here.
|
|
41
|
+
//
|
|
42
|
+
// Resume: the first thing a returning session needs to know. Preserves the inherited
|
|
43
|
+
// rules: a pending gate re-presents, a running slice re-observes, an
|
|
44
|
+
// unaccepted step re-runs.
|
|
45
|
+
export function nextAction(run) {
|
|
46
|
+
if (["completed", "partial", "blocked"].includes(run.status)) return `terminal:${run.status}`;
|
|
47
|
+
const openStep = run.steps.find((step) => step.status !== "accepted");
|
|
48
|
+
const sliceAction = nextSliceAction(run.slices);
|
|
49
|
+
for (const name of GATE_NAMES) {
|
|
50
|
+
const gate = run.gates[name];
|
|
51
|
+
// `pending` waits on a human; absent means the phase has not been reached, which is
|
|
52
|
+
// still not "done". But naming an absent gate while an agent is mid-round reads as
|
|
53
|
+
// "waiting on you" — so existing slice or step work is named instead.
|
|
54
|
+
if (gate === undefined) return sliceAction ?? (openStep ? `step:${openStep.agent}` : `gate:${name}`);
|
|
55
|
+
if (gate.status === "pending") return `gate:${name}`;
|
|
56
|
+
if (gate.status === "stop") return `stopped-at-gate:${name}`;
|
|
57
|
+
if (gate.status === "changes") return `changes-at-gate:${name}`;
|
|
58
|
+
if (name === "brief" && gate.status === "approved" && run.slices.length === 0) return "seed-slices";
|
|
59
|
+
}
|
|
60
|
+
if (sliceAction) return sliceAction;
|
|
61
|
+
if (openStep) return `step:${openStep.agent}`;
|
|
62
|
+
if (!run.pr_url) return "pr";
|
|
63
|
+
return "complete";
|
|
64
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// A review record lives at one path per subject, so each attempt overwrites the last. That is
|
|
2
|
+
// fine while a run succeeds and fatal when it does not: attempts are budgeted, exhausting the
|
|
3
|
+
// budget blocks a run, and `blocked` is final because `paths` freeze at seeding. The one
|
|
4
|
+
// artifact an operator needs to understand why a run blocked -- what the reviewer actually
|
|
5
|
+
// demanded on attempts 1 and 2 -- is the artifact the third attempt destroys.
|
|
6
|
+
//
|
|
7
|
+
// Run 216 is the case: its test-verifier was rejected twice and approved on the third. The
|
|
8
|
+
// reasons for both rejections are unrecoverable. They were reconstructed from commit subjects,
|
|
9
|
+
// which is guesswork wearing evidence's clothes.
|
|
10
|
+
//
|
|
11
|
+
// Instruction, not enforcement: losing a verdict cannot produce a false green, so a failed
|
|
12
|
+
// archive must never fail the step that earned it. The caller reports where the copy landed,
|
|
13
|
+
// or that it did not, rather than throwing.
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { basename, dirname, join } from "node:path";
|
|
16
|
+
import { writeProtectedJsonAtomic } from "../core/atomic-write.js";
|
|
17
|
+
import { ProtectedWriteError } from "../core/atomic-write.js";
|
|
18
|
+
|
|
19
|
+
// The attempt comes from the record, not from `--attempts`. The record is what is being
|
|
20
|
+
// preserved, and a snapshot filed under a number the record does not itself claim would
|
|
21
|
+
// misattribute a verdict to an attempt that did not produce it.
|
|
22
|
+
export function attemptArchiveRef(ref, attempt) {
|
|
23
|
+
const dir = dirname(ref);
|
|
24
|
+
const stem = basename(ref).replace(/\.json$/u, "");
|
|
25
|
+
return join(dir === "." ? "" : dir, `${stem}.attempt-${attempt}.json`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function archiveReviewAttempt(runDir, ref) {
|
|
29
|
+
if (typeof ref !== "string" || !ref.trim()) return null;
|
|
30
|
+
let record;
|
|
31
|
+
try {
|
|
32
|
+
record = JSON.parse(readFileSync(join(runDir, ref), "utf8"));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const attempt = record?.attempt;
|
|
37
|
+
if (!Number.isSafeInteger(attempt) || attempt < 1) return null;
|
|
38
|
+
const archive = attemptArchiveRef(ref, attempt);
|
|
39
|
+
try {
|
|
40
|
+
// createOnly: an archive that can be overwritten is not an archive. A second record for
|
|
41
|
+
// the same attempt loses to the first, which is the one the verdict was recorded against.
|
|
42
|
+
await writeProtectedJsonAtomic(runDir, archive, record, { createOnly: true });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const exists = error instanceof ProtectedWriteError && /already exists/u.test(error.message);
|
|
45
|
+
if (!exists) return null;
|
|
46
|
+
}
|
|
47
|
+
return archive;
|
|
48
|
+
}
|
package/state/schema.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// run.json adds mode, terminal_result, and pr_base to the inherited fifteen-field baseline.
|
|
2
|
+
// pr_base comes from init, is immutable through the envelope, and feeds status and Step 6.
|
|
3
|
+
// mode preserves autonomous intent; terminal_result records why a run stopped.
|
|
4
|
+
// base_commit was removed because no consumer used it and existing refs answer its questions.
|
|
5
|
+
// Every durable field needs a source, immutable route, and consumer.
|
|
6
|
+
// Closed key sets reject unknown agent-produced state instead of warning.
|
|
7
|
+
|
|
8
|
+
export const SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
// Where run state lives, relative to the repository. Was `.claude/factory`, and the privileged-path
|
|
11
|
+
// list hedged with `.opencode/factory` too: two host dotfiles in a host-agnostic package.
|
|
12
|
+
export const CONTROL_PLANE = ".factory";
|
|
13
|
+
|
|
14
|
+
export const RUN_KEYS = Object.freeze([
|
|
15
|
+
"version", "run_id", "issue_key", "branch", "worktree", "pr_base", "pr_draft", "created_at", "updated_at",
|
|
16
|
+
"status", "mode", "max_parallel_slices", "max_retries",
|
|
17
|
+
"gates", "steps", "slices", "validator", "terminal_result", "pr_url",
|
|
18
|
+
// Digest of the plan bytes the brief gate approved, so the seed ratifies that plan and not a
|
|
19
|
+
// later edit of the same filename. See the check in `slices-seed`.
|
|
20
|
+
"plan_digest",
|
|
21
|
+
"bootstrap_command", "bootstrap_exit",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const RUN_STATUSES = Object.freeze(["running", "completed", "blocked", "partial", "needs-human"]);
|
|
25
|
+
export const TERMINAL_STATUSES = Object.freeze(["completed", "blocked", "partial", "needs-human"]);
|
|
26
|
+
export const MODES = Object.freeze(["interactive", "headless", "autonomous"]);
|
|
27
|
+
|
|
28
|
+
export const GATE_NAMES = Object.freeze(["story", "brief", "pre_pr"]);
|
|
29
|
+
export const GATE_STATUSES = Object.freeze(["pending", "approved", "changes", "stop"]);
|
|
30
|
+
export const GATE_KEYS = Object.freeze(["status", "at", "artifact"]);
|
|
31
|
+
|
|
32
|
+
export const STEP_STATUSES = Object.freeze(["running", "accepted", "rejected", "blocked"]);
|
|
33
|
+
export const STEP_KEYS = Object.freeze(["agent", "status", "attempts", "review_ref", "evidence_ref"]);
|
|
34
|
+
|
|
35
|
+
export const SLICE_STATUSES = Object.freeze(["pending", "running", "review", "merged", "blocked"]);
|
|
36
|
+
export const SLICE_KEYS = Object.freeze([
|
|
37
|
+
"id", "stack", "depends_on", "status", "worktree", "branch", "attempts",
|
|
38
|
+
// `paths` is the ratified ownership declaration, seeded from plan/slices.json at
|
|
39
|
+
// the decompose gate. It lives here rather than being re-read from the plan at
|
|
40
|
+
// merge time so the set a transition validates against is the set the gate
|
|
41
|
+
// approved, and so ownership is decidable from run.json alone.
|
|
42
|
+
// base_ref is the integration head the slice branched from, recorded when it is
|
|
43
|
+
// dispatched. Without it a slice's own diff is undecidable after the merge - the
|
|
44
|
+
// integration branch by then contains the slice, so diffing against it is empty
|
|
45
|
+
// and every ownership check would silently pass.
|
|
46
|
+
//
|
|
47
|
+
// test_plan is ratified the same way and for the same reason. It replaced
|
|
48
|
+
// `observe --skip-tests-reason`, under which the orchestrator authored its own excuse
|
|
49
|
+
// for shipping an untested slice at the moment of observation - and any nonempty
|
|
50
|
+
// string was accepted, so "no tests needed" was a valid one. Whether a slice needs
|
|
51
|
+
// tests is a decision the decompose gate makes: a nonempty test_plan means an observed
|
|
52
|
+
// green run is required, and an empty one is an approved exemption. Empty by omission
|
|
53
|
+
// is not possible, because the field is required.
|
|
54
|
+
"paths", "path_amendments", "test_plan", "base_ref", "evidence_ref", "review_ref", "merge_commit",
|
|
55
|
+
]);
|
|
56
|
+
const PATH_AMENDMENT_KEYS = Object.freeze(["added_paths", "reason", "session", "at"]);
|
|
57
|
+
|
|
58
|
+
export const VALIDATOR_VERDICTS = Object.freeze(["GO", "GO-WITH-NITS", "NO-GO"]);
|
|
59
|
+
// reviewed_head is the fourth field, justified by attack 4: a verdict that does not
|
|
60
|
+
// name the head it judged cannot be refused once that head moves.
|
|
61
|
+
export const VALIDATOR_KEYS = Object.freeze(["verdict", "report", "reviewed_head", "loops"]);
|
|
62
|
+
export const TERMINAL_RESULT_KEYS = Object.freeze(["status", "reason"]);
|
|
63
|
+
|
|
64
|
+
// Finding 2: an invalid evidence ref was only rejected when consumed at merge, so a
|
|
65
|
+
// foreign path could be recorded into run.json and sit there looking legitimate. Refs
|
|
66
|
+
// are admitted at store time: run-local, no traversal, no absolute paths, and under
|
|
67
|
+
// the directory that owns them.
|
|
68
|
+
const REF_DIRS = Object.freeze({ evidence_ref: "evidence", review_ref: "reviews" });
|
|
69
|
+
|
|
70
|
+
function runLocalRef(errors, holder, key, path) {
|
|
71
|
+
const value = holder[key];
|
|
72
|
+
if (value === null || value === undefined) return;
|
|
73
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
74
|
+
errors.push({ path: `${path}.${key}`, message: "must be a non-empty string" });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const parts = value.split(/[\\/]/u);
|
|
78
|
+
if (value.startsWith("/") || parts.includes("..") || parts.includes(".")) {
|
|
79
|
+
errors.push({ path: `${path}.${key}`, message: "must be run-local without traversal" });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const dir = REF_DIRS[key];
|
|
83
|
+
if (dir && !value.startsWith(`${dir}/`)) {
|
|
84
|
+
errors.push({ path: `${path}.${key}`, message: `must be under ${dir}/` });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u;
|
|
89
|
+
const SHA = /^[0-9a-f]{40}$/u;
|
|
90
|
+
const ID = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u;
|
|
91
|
+
|
|
92
|
+
export class SchemaError extends Error {
|
|
93
|
+
constructor(errors) {
|
|
94
|
+
super(errors.map(({ path, message }) => `${path}: ${message}`).join("; "));
|
|
95
|
+
this.name = "SchemaError";
|
|
96
|
+
this.errors = errors;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function validateRun(run) {
|
|
101
|
+
const errors = [];
|
|
102
|
+
object(errors, run, "run", RUN_KEYS);
|
|
103
|
+
if (errors.length) throw new SchemaError(errors);
|
|
104
|
+
|
|
105
|
+
if (run.version !== SCHEMA_VERSION) errors.push({ path: "run.version", message: `must be ${SCHEMA_VERSION}` });
|
|
106
|
+
pattern(errors, run, "run_id", ID, "run");
|
|
107
|
+
enumValue(errors, run, "status", RUN_STATUSES, "run");
|
|
108
|
+
enumValue(errors, run, "mode", MODES, "run");
|
|
109
|
+
for (const key of ["branch", "worktree"]) required(errors, run, key, "run");
|
|
110
|
+
for (const key of ["created_at", "updated_at"]) pattern(errors, run, key, ISO, "run");
|
|
111
|
+
for (const key of ["max_parallel_slices", "max_retries"]) positiveInt(errors, run, key, "run");
|
|
112
|
+
for (const key of ["issue_key", "pr_base", "pr_url", "plan_digest"]) optionalString(errors, run, key, "run");
|
|
113
|
+
if (Object.hasOwn(run, "pr_draft") && typeof run.pr_draft !== "boolean") {
|
|
114
|
+
errors.push({ path: "run.pr_draft", message: "must be a boolean" });
|
|
115
|
+
}
|
|
116
|
+
if (Object.hasOwn(run, "bootstrap_command") !== Object.hasOwn(run, "bootstrap_exit")) {
|
|
117
|
+
errors.push({ path: "run.bootstrap_command", message: "must be present exactly when bootstrap_exit is present" });
|
|
118
|
+
} else if (Object.hasOwn(run, "bootstrap_command")) {
|
|
119
|
+
required(errors, run, "bootstrap_command", "run");
|
|
120
|
+
if (run.bootstrap_exit !== null) nonNegativeInt(errors, run, "bootstrap_exit", "run");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
gates(errors, run.gates);
|
|
124
|
+
steps(errors, run.steps);
|
|
125
|
+
slices(errors, run.slices);
|
|
126
|
+
validator(errors, run.validator);
|
|
127
|
+
terminalResult(errors, run.terminal_result, run.status);
|
|
128
|
+
|
|
129
|
+
if (errors.length) throw new SchemaError(errors);
|
|
130
|
+
return run;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function gates(errors, value) {
|
|
134
|
+
if (!isRecord(value)) return void errors.push({ path: "run.gates", message: "must be an object" });
|
|
135
|
+
const unknown = Object.keys(value).filter((key) => !GATE_NAMES.includes(key));
|
|
136
|
+
if (unknown.length) errors.push({ path: "run.gates", message: `unknown gates: ${unknown.join(", ")}` });
|
|
137
|
+
for (const name of GATE_NAMES) {
|
|
138
|
+
const gate = value[name];
|
|
139
|
+
if (gate === undefined) continue;
|
|
140
|
+
const path = `run.gates.${name}`;
|
|
141
|
+
if (!object(errors, gate, path, GATE_KEYS)) continue;
|
|
142
|
+
enumValue(errors, gate, "status", GATE_STATUSES, path);
|
|
143
|
+
if (gate.at !== null) optionalPattern(errors, gate, "at", ISO, path);
|
|
144
|
+
if (gate.artifact !== undefined && gate.artifact !== null) optionalString(errors, gate, "artifact", path);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function steps(errors, value) {
|
|
149
|
+
if (!Array.isArray(value)) return void errors.push({ path: "run.steps", message: "must be an array" });
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
value.forEach((step, index) => {
|
|
152
|
+
const path = `run.steps[${index}]`;
|
|
153
|
+
if (!object(errors, step, path, STEP_KEYS)) return;
|
|
154
|
+
required(errors, step, "agent", path);
|
|
155
|
+
enumValue(errors, step, "status", STEP_STATUSES, path);
|
|
156
|
+
positiveInt(errors, step, "attempts", path);
|
|
157
|
+
for (const key of ["review_ref", "evidence_ref"]) runLocalRef(errors, step, key, path);
|
|
158
|
+
if (seen.has(step.agent)) errors.push({ path: `${path}.agent`, message: "duplicate step agent" });
|
|
159
|
+
seen.add(step.agent);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function slices(errors, value) {
|
|
164
|
+
if (!Array.isArray(value)) return void errors.push({ path: "run.slices", message: "must be an array" });
|
|
165
|
+
const ids = new Set(value.filter(isRecord).map((slice) => slice.id));
|
|
166
|
+
// Finding 5: the id set existed only for dependency validation, so two slices could
|
|
167
|
+
// share an id. Every later update maps by id and would then hit both rows.
|
|
168
|
+
const seenIds = new Set();
|
|
169
|
+
for (const slice of value.filter(isRecord)) {
|
|
170
|
+
if (seenIds.has(slice.id)) errors.push({ path: "run.slices", message: `duplicate slice id '${slice.id}'` });
|
|
171
|
+
seenIds.add(slice.id);
|
|
172
|
+
}
|
|
173
|
+
value.forEach((slice, index) => {
|
|
174
|
+
const path = `run.slices[${index}]`;
|
|
175
|
+
if (!object(errors, slice, path, SLICE_KEYS)) return;
|
|
176
|
+
pattern(errors, slice, "id", ID, path);
|
|
177
|
+
required(errors, slice, "stack", path);
|
|
178
|
+
enumValue(errors, slice, "status", SLICE_STATUSES, path);
|
|
179
|
+
positiveInt(errors, slice, "attempts", path);
|
|
180
|
+
for (const key of ["worktree", "branch"]) nullableString(errors, slice, key, path);
|
|
181
|
+
for (const key of ["evidence_ref", "review_ref"]) runLocalRef(errors, slice, key, path);
|
|
182
|
+
if (slice.base_ref !== null && slice.base_ref !== undefined) optionalPattern(errors, slice, "base_ref", SHA, path);
|
|
183
|
+
// Finding 3: requiring base_ref only at "merged" let a slice run and review with
|
|
184
|
+
// none, then receive its first value on the merge transition - chosen after the
|
|
185
|
+
// fact to exclude earlier commits from the ownership diff. The branch point is a
|
|
186
|
+
// fact established when the slice is activated, so it is required from the moment
|
|
187
|
+
// the slice leaves "pending".
|
|
188
|
+
if (slice.status !== "pending" && !SHA.test(String(slice.base_ref))) {
|
|
189
|
+
errors.push({ path: `${path}.base_ref`, message: `is required once a slice is ${slice.status}` });
|
|
190
|
+
}
|
|
191
|
+
if (slice.merge_commit !== null && slice.merge_commit !== undefined) {
|
|
192
|
+
optionalPattern(errors, slice, "merge_commit", SHA, path);
|
|
193
|
+
}
|
|
194
|
+
// An array, possibly empty - empty is the approved exemption - but never absent, so a
|
|
195
|
+
// slice cannot acquire an exemption by omitting the field.
|
|
196
|
+
if (!Array.isArray(slice.test_plan) || !slice.test_plan.every((entry) => stringValue(entry))) {
|
|
197
|
+
errors.push({ path: `${path}.test_plan`, message: "must be an array of strings; empty means tests were waived at the gate" });
|
|
198
|
+
}
|
|
199
|
+
if (!Array.isArray(slice.paths) || slice.paths.length === 0 || !slice.paths.every((entry) => stringValue(entry))) {
|
|
200
|
+
errors.push({ path: `${path}.paths`, message: "must be a non-empty array of paths" });
|
|
201
|
+
} else if (slice.paths.some((entry) => !repositoryRelativePath(entry))) {
|
|
202
|
+
// A declared path that escapes the repository would make ownership
|
|
203
|
+
// unenforceable, so it is refused at admission rather than at merge.
|
|
204
|
+
errors.push({ path: `${path}.paths`, message: "must be repository-relative without '..'" });
|
|
205
|
+
}
|
|
206
|
+
pathAmendments(errors, slice, path);
|
|
207
|
+
if (!Array.isArray(slice.depends_on)) {
|
|
208
|
+
errors.push({ path: `${path}.depends_on`, message: "must be an array" });
|
|
209
|
+
} else {
|
|
210
|
+
for (const dep of slice.depends_on) {
|
|
211
|
+
if (!ids.has(dep)) errors.push({ path: `${path}.depends_on`, message: `unknown slice '${dep}'` });
|
|
212
|
+
if (dep === slice.id) errors.push({ path: `${path}.depends_on`, message: "slice cannot depend on itself" });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// A merged slice must record what it merged; otherwise the merge proof has
|
|
216
|
+
// nothing to bind to.
|
|
217
|
+
if (slice.status === "merged" && !SHA.test(String(slice.merge_commit))) {
|
|
218
|
+
errors.push({ path: `${path}.merge_commit`, message: "is required when a slice is merged" });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function pathAmendments(errors, slice, path) {
|
|
224
|
+
if (slice.path_amendments === undefined) return;
|
|
225
|
+
if (!Array.isArray(slice.path_amendments)) {
|
|
226
|
+
errors.push({ path: `${path}.path_amendments`, message: "must be an array" });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const recorded = new Set();
|
|
230
|
+
const currentPaths = Array.isArray(slice.paths) ? slice.paths : [];
|
|
231
|
+
slice.path_amendments.forEach((amendment, index) => {
|
|
232
|
+
const amendmentPath = `${path}.path_amendments[${index}]`;
|
|
233
|
+
if (!object(errors, amendment, amendmentPath, PATH_AMENDMENT_KEYS)) return;
|
|
234
|
+
for (const key of ["reason", "session"]) required(errors, amendment, key, amendmentPath);
|
|
235
|
+
pattern(errors, amendment, "at", ISO, amendmentPath);
|
|
236
|
+
if (!Array.isArray(amendment.added_paths) || amendment.added_paths.length === 0
|
|
237
|
+
|| !amendment.added_paths.every((entry) => repositoryRelativePath(entry))) {
|
|
238
|
+
errors.push({ path: `${amendmentPath}.added_paths`, message: "must be a non-empty array of repository-relative paths without '..'" });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
for (const added of amendment.added_paths) {
|
|
242
|
+
if (recorded.has(added)) errors.push({ path: `${amendmentPath}.added_paths`, message: `duplicate recorded path '${added}'` });
|
|
243
|
+
recorded.add(added);
|
|
244
|
+
if (!currentPaths.includes(added)) errors.push({ path: `${amendmentPath}.added_paths`, message: `recorded path '${added}' must exist in slice paths` });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function validator(errors, value) {
|
|
250
|
+
if (value === null || value === undefined) return;
|
|
251
|
+
if (!object(errors, value, "run.validator", VALIDATOR_KEYS)) return;
|
|
252
|
+
enumValue(errors, value, "verdict", VALIDATOR_VERDICTS, "run.validator");
|
|
253
|
+
nullableString(errors, value, "report", "run.validator");
|
|
254
|
+
pattern(errors, value, "reviewed_head", SHA, "run.validator");
|
|
255
|
+
nonNegativeInt(errors, value, "loops", "run.validator");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function terminalResult(errors, value, status) {
|
|
259
|
+
if (value === null || value === undefined) {
|
|
260
|
+
if (TERMINAL_STATUSES.includes(status) && status !== "completed") {
|
|
261
|
+
errors.push({ path: "run.terminal_result", message: `is required when status is ${status}` });
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (!object(errors, value, "run.terminal_result", TERMINAL_RESULT_KEYS)) return;
|
|
266
|
+
enumValue(errors, value, "status", TERMINAL_STATUSES, "run.terminal_result");
|
|
267
|
+
required(errors, value, "reason", "run.terminal_result");
|
|
268
|
+
if (value.status !== status && !(status === "running" && value.status === "needs-human")) {
|
|
269
|
+
errors.push({ path: "run.terminal_result.status", message: "must match run.status or preserve a resumed needs-human result" });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function object(errors, value, path, allowed) {
|
|
274
|
+
if (!isRecord(value)) {
|
|
275
|
+
errors.push({ path, message: "must be an object" });
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
279
|
+
if (unknown.length) {
|
|
280
|
+
errors.push({ path, message: `unknown keys: ${unknown.sort().join(", ")}` });
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function required(errors, holder, key, path) {
|
|
287
|
+
if (!stringValue(holder[key])) errors.push({ path: `${path}.${key}`, message: "must be a non-empty string" });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function optionalString(errors, holder, key, path) {
|
|
291
|
+
if (holder[key] === undefined || holder[key] === null) return;
|
|
292
|
+
required(errors, holder, key, path);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function nullableString(errors, holder, key, path) {
|
|
296
|
+
if (holder[key] === null || holder[key] === undefined) return;
|
|
297
|
+
required(errors, holder, key, path);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function pattern(errors, holder, key, regex, path) {
|
|
301
|
+
if (typeof holder[key] !== "string" || !regex.test(holder[key])) {
|
|
302
|
+
errors.push({ path: `${path}.${key}`, message: `must match ${regex.source}` });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function optionalPattern(errors, holder, key, regex, path) {
|
|
307
|
+
if (holder[key] === undefined || holder[key] === null) return;
|
|
308
|
+
pattern(errors, holder, key, regex, path);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function enumValue(errors, holder, key, allowed, path) {
|
|
312
|
+
if (!allowed.includes(holder[key])) {
|
|
313
|
+
errors.push({ path: `${path}.${key}`, message: `must be one of ${allowed.join(" | ")}` });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function positiveInt(errors, holder, key, path) {
|
|
318
|
+
if (!Number.isSafeInteger(holder[key]) || holder[key] < 1) {
|
|
319
|
+
errors.push({ path: `${path}.${key}`, message: "must be a positive integer" });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function nonNegativeInt(errors, holder, key, path) {
|
|
324
|
+
if (!Number.isSafeInteger(holder[key]) || holder[key] < 0) {
|
|
325
|
+
errors.push({ path: `${path}.${key}`, message: "must be a non-negative integer" });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isRecord(value) {
|
|
330
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function stringValue(value) {
|
|
334
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function repositoryRelativePath(value) {
|
|
338
|
+
return stringValue(value) && !value.startsWith("/") && !value.split("/").includes("..");
|
|
339
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// factory.lock retains the predecessor's session-lock record shape:
|
|
2
|
+
// { session, run_id, branch, claimed_at, heartbeat_at }
|
|
3
|
+
// Unlike the brief run-json transition lock, this answers which session owns the
|
|
4
|
+
// whole run and enables resume, steal, or abort. A fresh heartbeat means another
|
|
5
|
+
// session is working; one older than the TTL may be stolen.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { writeProtectedJsonAtomic } from "../core/atomic-write.js";
|
|
9
|
+
import { rm } from "node:fs/promises";
|
|
10
|
+
import { withRunJsonLock } from "../core/run-lock.js";
|
|
11
|
+
|
|
12
|
+
export const SESSION_LOCK_FILE = "factory.lock";
|
|
13
|
+
export const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
|
|
14
|
+
// `pid` is no longer written, but stays listed so locks written before it was dropped
|
|
15
|
+
// still validate. Removing it would make every pre-existing lock fail the unknown-key
|
|
16
|
+
// check below, read as absent, and let a second session claim a run that is still being
|
|
17
|
+
// worked -- the dangerous direction. Why it was dropped, from #194: the recorded pid was
|
|
18
|
+
// the CLI or the transient shell that invoked it, never the run's owner, so it could not
|
|
19
|
+
// answer the liveness question its presence implied.
|
|
20
|
+
export const SESSION_LOCK_KEYS = Object.freeze([
|
|
21
|
+
"session", "pid", "run_id", "branch", "claimed_at", "heartbeat_at",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export class SessionLockHeldError extends Error {
|
|
25
|
+
constructor(owner) {
|
|
26
|
+
super(`run '${owner.run_id}' is held by session ${owner.session} (heartbeat ${owner.heartbeat_at})`);
|
|
27
|
+
this.name = "SessionLockHeldError";
|
|
28
|
+
this.owner = owner;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readSessionLock(runDir) {
|
|
33
|
+
try {
|
|
34
|
+
const value = JSON.parse(readFileSync(join(runDir, SESSION_LOCK_FILE), "utf8"));
|
|
35
|
+
return isValidLock(value) ? value : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// "fresh" | "stale" | "absent". The caller routes on this; it is the only
|
|
42
|
+
// question the lock answers.
|
|
43
|
+
export function inspectSessionLock(runDir, { now = Date.now(), ttlMs = DEFAULT_SESSION_TTL_MS } = {}) {
|
|
44
|
+
const owner = readSessionLock(runDir);
|
|
45
|
+
if (!owner) return { state: "absent", owner: null };
|
|
46
|
+
const ageMs = now - Date.parse(owner.heartbeat_at);
|
|
47
|
+
// A future heartbeat is not evidence of staleness, so it counts as fresh.
|
|
48
|
+
if (!Number.isFinite(ageMs) || ageMs <= ttlMs) return { state: "fresh", owner };
|
|
49
|
+
return { state: "stale", owner };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function claimSessionLock(runDir, { session, runId, branch, now, ttlMs, force = false } = {}) {
|
|
53
|
+
return withRunJsonLock(runDir, async () => {
|
|
54
|
+
if (!session) throw new Error("factory lock requires a session id");
|
|
55
|
+
const at = new Date(now ?? Date.now()).toISOString();
|
|
56
|
+
const observed = inspectSessionLock(runDir, { now: now ?? Date.now(), ttlMs });
|
|
57
|
+
if (observed.state === "fresh" && observed.owner.session !== session && !force) {
|
|
58
|
+
throw new SessionLockHeldError(observed.owner);
|
|
59
|
+
}
|
|
60
|
+
const owner = {
|
|
61
|
+
session,
|
|
62
|
+
run_id: runId,
|
|
63
|
+
branch: branch ?? null,
|
|
64
|
+
// Re-claiming your own lock preserves when you first took it.
|
|
65
|
+
claimed_at: observed.owner?.session === session ? observed.owner.claimed_at : at,
|
|
66
|
+
heartbeat_at: at,
|
|
67
|
+
};
|
|
68
|
+
await writeProtectedJsonAtomic(runDir, SESSION_LOCK_FILE, owner);
|
|
69
|
+
return { ...owner, stolen_from: observed.state === "stale" || force ? observed.owner : null };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function refreshSessionLock(runDir, { session, now } = {}) {
|
|
74
|
+
return withRunJsonLock(runDir, async () => {
|
|
75
|
+
const owner = readSessionLock(runDir);
|
|
76
|
+
if (!owner) throw new Error("no factory.lock to refresh");
|
|
77
|
+
// Refreshing someone else's lock would silently extend a run you do not own.
|
|
78
|
+
if (session && owner.session !== session) throw new SessionLockHeldError(owner);
|
|
79
|
+
const next = { ...owner, heartbeat_at: new Date(now ?? Date.now()).toISOString() };
|
|
80
|
+
await writeProtectedJsonAtomic(runDir, SESSION_LOCK_FILE, next);
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function releaseSessionLock(runDir, { session } = {}) {
|
|
86
|
+
return withRunJsonLock(runDir, async () => {
|
|
87
|
+
const owner = readSessionLock(runDir);
|
|
88
|
+
if (!owner) return { released: false, reason: "absent" };
|
|
89
|
+
if (session && owner.session !== session) throw new SessionLockHeldError(owner);
|
|
90
|
+
await rm(join(runDir, SESSION_LOCK_FILE), { force: true });
|
|
91
|
+
return { released: true, reason: null };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isValidLock(value) {
|
|
96
|
+
return Boolean(value)
|
|
97
|
+
&& typeof value === "object"
|
|
98
|
+
&& !Array.isArray(value)
|
|
99
|
+
&& Object.keys(value).every((key) => SESSION_LOCK_KEYS.includes(key))
|
|
100
|
+
&& typeof value.session === "string" && value.session.trim().length > 0
|
|
101
|
+
&& typeof value.run_id === "string" && value.run_id.trim().length > 0
|
|
102
|
+
&& Number.isFinite(Date.parse(value.claimed_at || ""))
|
|
103
|
+
&& Number.isFinite(Date.parse(value.heartbeat_at || ""));
|
|
104
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The sole run.json write path is private to bin/factory.js.
|
|
2
|
+
// The package root exports reads, preserving the plugin's read-only boundary.
|
|
3
|
+
//
|
|
4
|
+
// lock -> read -> validate -> apply -> validate -> CAS -> reobserve -> CAS -> rename
|
|
5
|
+
//
|
|
6
|
+
// Contracts validate every family before atomic rename replacement.
|
|
7
|
+
// `apply` receives a structured clone, never the lock, file, or rename.
|
|
8
|
+
// Initialization is the only write outside this path and uses create-only publication.
|
|
9
|
+
import { validateRun } from "./schema.js";
|
|
10
|
+
import { coordinateRunJsonTransition } from "../core/write-core.js";
|
|
11
|
+
import { FAMILY_CONTRACTS } from "../core/contracts.js";
|
|
12
|
+
|
|
13
|
+
export async function transition(runDir, { participants, apply, reobservers, hooks, finalGuard } = {}) {
|
|
14
|
+
const descriptor = Object.freeze({
|
|
15
|
+
participants: Object.freeze((participants ?? []).map((entry) => Object.freeze({ ...entry }))),
|
|
16
|
+
apply,
|
|
17
|
+
});
|
|
18
|
+
return coordinateRunJsonTransition(runDir, {
|
|
19
|
+
contracts: FAMILY_CONTRACTS,
|
|
20
|
+
descriptor,
|
|
21
|
+
validateRun,
|
|
22
|
+
reobservers: reobservers ?? new Map(),
|
|
23
|
+
atomicWriteHooks: hooks,
|
|
24
|
+
finalGuard,
|
|
25
|
+
});
|
|
26
|
+
}
|