feature-factory 0.8.1 → 0.8.3
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/README.md +11 -0
- package/WORKFLOW.md +72 -2
- package/bin/factory.js +54 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -106,6 +106,17 @@ workflow, outcome, or acceptance criteria, so it reaches Gate 1 with nothing to
|
|
|
106
106
|
non-zero exit instead refuses immediately, names the reference, and creates no session or run. Only the
|
|
107
107
|
resolver author knows which of the two cases it is in, so the contract cannot make the choice for it.
|
|
108
108
|
|
|
109
|
+
A `needs-human` terminalization is followed by a control-plane snapshot: the driver copies the run
|
|
110
|
+
directory to `$O/.factory/.parked/<R>` immediately after recording the park. A parked run waits for
|
|
111
|
+
outside intervention, and its control plane otherwise exists only inside the sandbox, so anything that
|
|
112
|
+
removed the sandbox destroyed the manifest and every accepted gate with it. `.parked` cannot be a run
|
|
113
|
+
id, so a snapshot never occupies the completed archive at `$O/.factory/<R>` and never blocks
|
|
114
|
+
re-initialising the same run id. It is published by a staged, verified swap, so a failed later park cannot degrade the last good
|
|
115
|
+
snapshot. A failed snapshot is reported and never prevents the park. `blocked`
|
|
116
|
+
and `partial` are not snapshotted, and a snapshot is evidence for recovery rather than a resumable run.
|
|
117
|
+
|
|
118
|
+
Qualified status reports `park_snapshot` for a parked run: the published path, or `null` when no snapshot
|
|
119
|
+
exists. That is how an outside observer verifies the snapshot happened rather than assuming it.
|
|
109
120
|
Malformed config, malformed payload, a non-zero exit, or unavailable exit status refuses before any
|
|
110
121
|
run effect and never falls back:
|
|
111
122
|
|
package/WORKFLOW.md
CHANGED
|
@@ -148,9 +148,24 @@ resume; invocation flags do not select resumed behavior:
|
|
|
148
148
|
An inability to ask a human never promotes interactive or headless to autonomous.
|
|
149
149
|
|
|
150
150
|
Mode result needs-human means parked and explicitly resumable; only completed, partial, and blocked are final.
|
|
151
|
-
|
|
151
|
+
**The parked stop is one ordered sequence, and every rule in this document that says a run parks enters
|
|
152
|
+
it.** Those rules name the cause; they do not restate the steps. Execute all three, in order, before
|
|
153
|
+
reporting anything:
|
|
154
|
+
|
|
155
|
+
1. Enter the parked stop with factory terminal R needs-human --reason TEXT; leave it only by explicit factory resume R --session $SESSION_ID --repo S, which refuses unless that session already holds a fresh lock: claim, then verify, then resume.
|
|
156
|
+
2. Immediately after recording a `needs-human` terminalization, and before reporting the park to the operator,
|
|
157
|
+
publish a parked control-plane snapshot exactly as *Parked control-plane snapshot* below requires. A park
|
|
158
|
+
is not reported until that snapshot is published or its failure is recorded in the report.
|
|
159
|
+
3. Report top-level needs-human as parked with its reason and explicit factory resume command.
|
|
160
|
+
|
|
161
|
+
A park that completes only step 1 is an unreported park with no recovery evidence, which is the state
|
|
162
|
+
this sequence exists to prevent. Verify step 2 the way an outside observer would: qualified status
|
|
163
|
+
reports `park_snapshot` as the published path, or `null` when no snapshot exists. Status reports the path
|
|
164
|
+
only while the snapshot is a complete copy of the live plane by the step 3 inventory and its manifest still
|
|
165
|
+
matches the live one byte for byte, so `null` also covers an interrupted or altered copy and a snapshot
|
|
166
|
+
left by an earlier park: neither is evidence for this park. Publishing again is what makes it correspond.
|
|
167
|
+
|
|
152
168
|
For top-level needs-human, status exposes the durable next action, but no command may execute it before explicit factory resume.
|
|
153
|
-
Report top-level needs-human as parked with its reason and explicit factory resume command.
|
|
154
169
|
Retain the sandbox for top-level needs-human while parked, then explicitly resume it after the external fix.
|
|
155
170
|
A park that asks a question about the request itself -- a contradiction between criteria, a scope lock,
|
|
156
171
|
or a pinned constraint -- is not fixed by resuming. Resume continues from the existing manifest and
|
|
@@ -188,6 +203,61 @@ an observably clean tree: `worktree_clean` records false, the suite is skipped,
|
|
|
188
203
|
classifies `unavailable`, which parks the run. Ignoring the root paths instead is not the fix — `.gitignore`
|
|
189
204
|
is privileged precisely because ignoring a file conceals it from these checks.
|
|
190
205
|
|
|
206
|
+
### Parked control-plane snapshot
|
|
207
|
+
|
|
208
|
+
A parked run waits for outside intervention, and its control plane otherwise exists only inside the
|
|
209
|
+
sandbox: the completed handoff is the only thing that archives it, and that handoff is entered only for
|
|
210
|
+
`completed`. So anything that removed the sandbox destroyed the manifest, the approved gates, the ratified
|
|
211
|
+
plan and every review verdict, leaving the run neither resumable nor reconstructable.
|
|
212
|
+
|
|
213
|
+
Publish the live plane `P` to `$O/.factory/.parked/$R`. Inspect `$O/.factory` and `$O/.factory/.parked` with
|
|
214
|
+
non-following metadata reads, creating each missing parent one directory at a time and requiring any present
|
|
215
|
+
one to be a real directory rather than a symbolic link. Never write through a symlinked parent, and never
|
|
216
|
+
write anywhere but under `$O/.factory/.parked`.
|
|
217
|
+
|
|
218
|
+
**Publication has exactly one commit point: the rename that puts a verified staging tree onto the canonical
|
|
219
|
+
path.** Every failure rule below is stated relative to it, because "leave the previous snapshot untouched"
|
|
220
|
+
and "clean up the prior copy" are contradictory instructions once that rename has happened.
|
|
221
|
+
|
|
222
|
+
1. **Preflight.** Require no entry at `$O/.factory/.parked/.staging-$R`. A residual
|
|
223
|
+
`$O/.factory/.parked/.prior-$R` is the trace of an earlier publication whose cleanup did not finish, not
|
|
224
|
+
a snapshot to preserve: remove it before staging, and if that removal fails, report it and stop without
|
|
225
|
+
touching the canonical snapshot or staging anything.
|
|
226
|
+
2. **Stage.** Copy `P` to `.staging-$R`, preserving every entry, its mode, and symlinks as symlinks. `W` is
|
|
227
|
+
outside `P`; do not copy slice worktrees or any other part of `S`.
|
|
228
|
+
3. **Verify.** Build source and destination inventories exactly as the completed archive does — every
|
|
229
|
+
entry's relative path, type and mode, a SHA-256 for each regular file, a link target for each symlink,
|
|
230
|
+
sorted lexically — and require exact equality. An unverified staging tree is never published.
|
|
231
|
+
4. **Commit.** With no snapshot at the canonical path, rename `.staging-$R` onto it; that rename is the
|
|
232
|
+
commit point. With one present, first rename the canonical snapshot to `.prior-$R`, then rename
|
|
233
|
+
`.staging-$R` onto the canonical path; that second rename is the commit point. If the first rename
|
|
234
|
+
succeeds and the second fails, rename `.prior-$R` back onto the canonical path and report: nothing was
|
|
235
|
+
committed.
|
|
236
|
+
5. **Before the commit point, no publication has occurred.** Any failure leaves the previous canonical
|
|
237
|
+
snapshot in place — restoring it from `.prior-$R` when it had already been moved — removes only
|
|
238
|
+
`.staging-$R`, and records the failure in the park report.
|
|
239
|
+
6. **After the commit point, the published snapshot is authoritative and is never rolled back.** Removing
|
|
240
|
+
`.prior-$R` is cleanup, not part of publication: if it fails, report a cleanup warning naming the
|
|
241
|
+
residual path and leave the published snapshot exactly as committed. A later park removes that residual
|
|
242
|
+
at preflight, as step 1 requires.
|
|
243
|
+
|
|
244
|
+
`.staging-$R` and `.prior-$R` cannot be run ids, so neither is ever a manifest candidate.
|
|
245
|
+
|
|
246
|
+
Three properties make this safe to do at a park rather than only at completion:
|
|
247
|
+
|
|
248
|
+
1. `.parked` cannot be a run id, so a snapshot never occupies the completed archive at `$O/.factory/$R` and
|
|
249
|
+
never becomes a manifest candidate. A run may park, resume, and later complete with its handoff archive
|
|
250
|
+
unaffected, and relaunching the same run id is not blocked by a snapshot left behind.
|
|
251
|
+
2. It never touches `S`. The snapshot is a copy out; removal remains the completed handoff's guarded step
|
|
252
|
+
and nothing else's.
|
|
253
|
+
3. A snapshot failure is reported and does not prevent or undo the park. Refusing to park because a copy
|
|
254
|
+
failed would leave `status: running` with nothing alive, which every health signal misreads — a worse
|
|
255
|
+
outcome than a missing snapshot.
|
|
256
|
+
|
|
257
|
+
A snapshot is evidence for recovery, not a resumable run: resume operates on the sandbox manifest. Do not
|
|
258
|
+
publish one for `blocked` or `partial`, which are not resumable, and never treat a snapshot as authority
|
|
259
|
+
over the live plane.
|
|
260
|
+
|
|
191
261
|
At every interactive gate, `changes: <feedback>` records `changes`, follows
|
|
192
262
|
`changes-at-gate:<name>`, revises only the affected stage, and re-presents it pending. `stop` requires
|
|
193
263
|
qualified status `next: stopped-at-gate:<name>` and releases the driver's lock. This is an unlocked
|
package/bin/factory.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// The orchestrator calls this CLI instead of writing control-plane state directly.
|
|
5
5
|
// Flags are declared per command; unknown options fail rather than becoming missing fields.
|
|
6
6
|
// Schema validation surrounds every state write.
|
|
7
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
|
|
8
|
-
import { join, resolve } from "node:path";
|
|
7
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
9
9
|
import { pathToFileURL } from "node:url";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { isDeepStrictEqual } from "node:util";
|
|
@@ -153,6 +153,57 @@ function briefDigestFor(decision, state, runDir) {
|
|
|
153
153
|
return state.plan_digest;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// Observability, not enforcement: the copy stays a driver step, because this CLI is forbidden copy and
|
|
157
|
+
// delete primitives. What this answers has to be exact, and two earlier versions were not. "Does the
|
|
158
|
+
// pathname exist" reported a snapshot from an earlier park as this park's evidence. Matching only `run.json`
|
|
159
|
+
// then proved one file was copied after the current terminalization, not that the publication finished --
|
|
160
|
+
// a driver that created the directory and copied that file first, or an interrupted copy, still read as
|
|
161
|
+
// published. Both were false greens in the exact case this exists to catch, and both were caught in review.
|
|
162
|
+
// So compare the inventory the publication contract already defines, in the same terms it defines it:
|
|
163
|
+
// `.` and every descendant, each recording relative path, type, permission mode, SHA-256 for a regular
|
|
164
|
+
// file and link target for a symlink, sorted lexically by relative path, with unsupported entry types
|
|
165
|
+
// rejected. Two earlier versions of this comparison were narrower than the contract they claimed to check.
|
|
166
|
+
// Recording sizes rather than digests -- on the theory that hashing was too costly for a continuously
|
|
167
|
+
// polled command -- passed a same-length byte change and a mode-only change as faithful. Then walking into
|
|
168
|
+
// the root without recording it passed a snapshot whose own directory mode differed from the plane's. Both
|
|
169
|
+
// reported a tree that fails the contract's verification as published, while the documentation said altered
|
|
170
|
+
// trees yield `null`, and a signal that disagrees with its own description is the thing this whole change
|
|
171
|
+
// exists to remove. Hashing the plane costs about a millisecond; the cost theory was right and its
|
|
172
|
+
// conclusion was wrong. Both caught in review.
|
|
173
|
+
// Every path component is checked with `lstat` and never followed, since `lstat` on the final entry alone
|
|
174
|
+
// still follows intermediate symlinks.
|
|
175
|
+
function planeInventory(root) {
|
|
176
|
+
const entries = [];
|
|
177
|
+
const record = (rel, full) => {
|
|
178
|
+
const stat = lstatSync(full);
|
|
179
|
+
const mode = (stat.mode & 0o7777).toString(8);
|
|
180
|
+
if (stat.isSymbolicLink()) entries.push(`${rel} l ${mode} ${readlinkSync(full)}`);
|
|
181
|
+
else if (stat.isDirectory()) {
|
|
182
|
+
entries.push(`${rel} d ${mode}`);
|
|
183
|
+
for (const name of readdirSync(full)) record(rel === "." ? name : `${rel}/${name}`, join(full, name));
|
|
184
|
+
} else if (stat.isFile()) entries.push(`${rel} f ${mode} ${createHash("sha256").update(readFileSync(full)).digest("hex")}`);
|
|
185
|
+
else throw new CliError(`unsupported entry type in '${full}'`);
|
|
186
|
+
};
|
|
187
|
+
record(".", root);
|
|
188
|
+
return entries.sort().join("\n");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function observedParkSnapshot(repo, runId, runDir) {
|
|
192
|
+
const container = dirname(repo);
|
|
193
|
+
if (basename(container) !== ".factory-sandboxes" || basename(repo) !== runId) return null;
|
|
194
|
+
const operatorRoot = dirname(container);
|
|
195
|
+
const candidate = join(operatorRoot, CONTROL_PLANE, ".parked", runId);
|
|
196
|
+
try {
|
|
197
|
+
for (const component of [join(operatorRoot, CONTROL_PLANE), join(operatorRoot, CONTROL_PLANE, ".parked"), candidate]) {
|
|
198
|
+
if (!lstatSync(component).isDirectory()) return null;
|
|
199
|
+
}
|
|
200
|
+
if (planeInventory(candidate) !== planeInventory(runDir)) return null;
|
|
201
|
+
return readFileSync(join(candidate, "run.json")).equals(readFileSync(join(runDir, "run.json"))) ? candidate : null;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
156
207
|
function runDirFor(flags, runId) {
|
|
157
208
|
if (!runId) throw new CliError("a <run-id> is required");
|
|
158
209
|
return join(resolve(flags.repo ?? process.cwd()), CONTROL_PLANE, runId);
|
|
@@ -903,6 +954,7 @@ const HANDLERS = {
|
|
|
903
954
|
branch: run.branch,
|
|
904
955
|
pr_base: run.pr_base ?? null,
|
|
905
956
|
publishing_identity: run.publishing_identity ?? null,
|
|
957
|
+
park_snapshot: run.status === "needs-human" ? observedParkSnapshot(resolve(flags.repo ?? process.cwd()), runId, runDir) : null,
|
|
906
958
|
pr_draft: run.pr_draft ?? true,
|
|
907
959
|
lock: lock.state, dead_lock: run.status === "running" && lock.state === "stale",
|
|
908
960
|
lock_session: lock.owner?.session ?? null,
|