@tiphys/kernel 0.0.0 → 0.1.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 +611 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +278 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +14 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +59 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +828 -0
- package/dist/src/checks.js +3314 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +57 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +320 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/doctor.d.ts +35 -0
- package/dist/src/commands/doctor.js +605 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +128 -0
- package/dist/src/commands/lock.d.ts +1 -0
- package/dist/src/commands/lock.js +229 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +128 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +146 -0
- package/dist/src/commands/status.d.ts +19 -0
- package/dist/src/commands/status.js +120 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +79 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +360 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/exec/env.d.ts +128 -0
- package/dist/src/exec/env.js +190 -0
- package/dist/src/fleet.d.ts +51 -0
- package/dist/src/fleet.js +80 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1163 -0
- package/dist/src/gates/coverage.d.ts +284 -0
- package/dist/src/gates/coverage.js +701 -0
- package/dist/src/gates/credentials.d.ts +74 -0
- package/dist/src/gates/credentials.js +533 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +390 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +566 -0
- package/dist/src/gates/run.js +1536 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +927 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +32 -0
- package/dist/src/hooks.js +62 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +178 -0
- package/dist/src/lock.js +500 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +2 -0
- package/dist/src/path-identity.js +10 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +130 -0
- package/dist/src/pool.js +721 -0
- package/dist/src/roles.d.ts +430 -0
- package/dist/src/roles.js +734 -0
- package/dist/src/spawn.d.ts +177 -0
- package/dist/src/spawn.js +332 -0
- package/dist/src/status.d.ts +91 -0
- package/dist/src/status.js +119 -0
- package/dist/src/task.d.ts +264 -0
- package/dist/src/task.js +305 -0
- package/dist/src/teardown.d.ts +32 -0
- package/dist/src/teardown.js +314 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +230 -0
- package/dist/src/validate.js +732 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +859 -0
- package/dist/src/witness/run.d.ts +274 -0
- package/dist/src/witness/run.js +1327 -0
- package/dist/src/witness/spec.d.ts +102 -0
- package/dist/src/witness/spec.js +253 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +390 -0
- package/gates.manifest.json +195 -0
- package/package.json +57 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +460 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +81 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +166 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +289 -0
- package/schemas/work-history.schema.json +183 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STATUS LINE (kernel plan M3, M3-P1 steps 5 and 6; R-084).
|
|
3
|
+
*
|
|
4
|
+
* The orchestrator narrates to the owner at MILESTONES ONLY: a phase merged,
|
|
5
|
+
* an incident, a decision needed. Routine noise gets a one-line ack and
|
|
6
|
+
* nothing else. That sparseness is expressed STRUCTURALLY in
|
|
7
|
+
* `schemas/status-line.schema.json` rather than as an instruction: the state
|
|
8
|
+
* vocabulary has exactly five members and none of them means "still working",
|
|
9
|
+
* so a heartbeat is not a thing this contract can say.
|
|
10
|
+
*
|
|
11
|
+
* CONSTRAINT C-1, NAMED BECAUSE IT DECIDES THE SHAPE OF THIS MODULE.
|
|
12
|
+
* "Never read current state from the tail of an append-only log." The stream
|
|
13
|
+
* at `state/status/stream.jsonl` is the HISTORY and is append-only. The
|
|
14
|
+
* current state is `state/status/current.json`, a whole document rewritten
|
|
15
|
+
* atomically on every emit. `readCurrent` opens `current.json` and nothing
|
|
16
|
+
* else; it does not know the stream's path. A truncated, corrupted or
|
|
17
|
+
* half-written stream therefore cannot change what `tiphys status show`
|
|
18
|
+
* reports, and that is the property criterion 7 witnesses in both directions.
|
|
19
|
+
*
|
|
20
|
+
* ATOMIC REWRITE means write a temp file beside the target and rename. A
|
|
21
|
+
* rename within one directory is atomic on POSIX, so a reader either sees the
|
|
22
|
+
* whole previous document or the whole new one and never a partial write.
|
|
23
|
+
*/
|
|
24
|
+
import { mkdirSync, renameSync, writeFileSync, appendFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { refuseOpenForWrite, readRegularFileIfPresent } from "./task.js";
|
|
27
|
+
/** Where the status files live inside a fleet home. */
|
|
28
|
+
export const STATUS_DIR = join("state", "status");
|
|
29
|
+
export const STREAM_FILE = join(STATUS_DIR, "stream.jsonl");
|
|
30
|
+
export const CURRENT_FILE = join(STATUS_DIR, "current.json");
|
|
31
|
+
/**
|
|
32
|
+
* The five supervisor-actionable states, blueprint section 5. Duplicated
|
|
33
|
+
* from `schemas/status-line.schema.json` so the CLI can reject a bad state
|
|
34
|
+
* before it composes a record; `test/status.test.ts` asserts the two agree,
|
|
35
|
+
* because a duplicated vocabulary that nothing compares is a vocabulary that
|
|
36
|
+
* drifts.
|
|
37
|
+
*/
|
|
38
|
+
export const STATUS_STATES = [
|
|
39
|
+
"blocked",
|
|
40
|
+
"done",
|
|
41
|
+
"failed",
|
|
42
|
+
"needs-decision",
|
|
43
|
+
"phase-change",
|
|
44
|
+
];
|
|
45
|
+
/** RFC 3339 UTC to whole seconds, the form the schema's pattern accepts. */
|
|
46
|
+
export function nowIso() {
|
|
47
|
+
return `${new Date().toISOString().slice(0, 19)}Z`;
|
|
48
|
+
}
|
|
49
|
+
export function makeStatusRecord(input) {
|
|
50
|
+
return {
|
|
51
|
+
kind: "status-line",
|
|
52
|
+
at: input.at ?? nowIso(),
|
|
53
|
+
run: input.run,
|
|
54
|
+
project: input.project,
|
|
55
|
+
state: input.state,
|
|
56
|
+
detail: input.detail ?? "",
|
|
57
|
+
refs: input.refs ?? [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Append ONE line to the stream, then rewrite `current.json` atomically.
|
|
62
|
+
*
|
|
63
|
+
* The order is deliberate and is the same ordering rule M2's gate runner
|
|
64
|
+
* learned: the durable history is written first, so a crash between the two
|
|
65
|
+
* leaves a history that is ahead of the pointer rather than a pointer that
|
|
66
|
+
* names an event no record supports.
|
|
67
|
+
*/
|
|
68
|
+
export function emitStatus(fleetRoot, record) {
|
|
69
|
+
const directory = join(fleetRoot, STATUS_DIR);
|
|
70
|
+
mkdirSync(directory, { recursive: true });
|
|
71
|
+
const streamPath = join(fleetRoot, STREAM_FILE);
|
|
72
|
+
const currentPath = join(fleetRoot, CURRENT_FILE);
|
|
73
|
+
const temporaryPath = `${currentPath}.tmp`;
|
|
74
|
+
for (const path of [streamPath, currentPath, temporaryPath]) {
|
|
75
|
+
const refusal = refuseOpenForWrite(path);
|
|
76
|
+
if (refusal !== undefined) {
|
|
77
|
+
return { ok: false, reason: refusal };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
appendFileSync(streamPath, `${JSON.stringify(record)}\n`, "utf8");
|
|
81
|
+
writeFileSync(temporaryPath, `${JSON.stringify(record, undefined, 2)}\n`, "utf8");
|
|
82
|
+
renameSync(temporaryPath, currentPath);
|
|
83
|
+
return { ok: true, record };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read the current status. C-1: this function knows only `current.json`.
|
|
87
|
+
*
|
|
88
|
+
* It does not accept a stream path, does not fall back to the stream, and
|
|
89
|
+
* has no code path that opens it. That is what makes criterion 7 a
|
|
90
|
+
* demonstrable property rather than a convention: an implementation that
|
|
91
|
+
* reads the stream is a DIFFERENT function, and the criterion requires it to
|
|
92
|
+
* be written, shown red against corrupt stream bytes, and reverted.
|
|
93
|
+
*/
|
|
94
|
+
export function readCurrent(fleetRoot) {
|
|
95
|
+
const path = join(fleetRoot, CURRENT_FILE);
|
|
96
|
+
const read = readRegularFileIfPresent(path);
|
|
97
|
+
if (read.kind === "absent") {
|
|
98
|
+
return { ok: false, reason: `no status has been emitted in ${fleetRoot}` };
|
|
99
|
+
}
|
|
100
|
+
if (read.kind === "refused") {
|
|
101
|
+
return { ok: false, reason: read.reason };
|
|
102
|
+
}
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(read.body);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: `${path} is not readable as a status record: ${error.message}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, record: parsed };
|
|
114
|
+
}
|
|
115
|
+
/** One line, the form `tiphys status show` prints. */
|
|
116
|
+
export function renderStatus(record) {
|
|
117
|
+
const refs = record.refs.length === 0 ? "" : ` refs=${record.refs.join(",")}`;
|
|
118
|
+
return `${record.at} ${record.state} run=${record.run} project=${record.project}${record.detail === "" ? "" : ` ${record.detail}`}${refs}`;
|
|
119
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { Fleet } from "./fleet.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Task state (kernel plan v1, M1-P4 step 1). A task's durable state is
|
|
4
|
+
* <fleet>/tasks/<id>/, holding plain JSON per plan decision D-3:
|
|
5
|
+
*
|
|
6
|
+
* meta.json the task record written by spawn and closed by teardown
|
|
7
|
+
* brief.md the assembled brief (src/brief.ts)
|
|
8
|
+
* turn-end the payload's completion record (src/hooks.ts)
|
|
9
|
+
* executor.json the launch record written by the executor adapter
|
|
10
|
+
* report.md a scout's report, the teardown gate for shape scout
|
|
11
|
+
*
|
|
12
|
+
* PLAN CONSTRAINT C-1 (FM-052, FM-049), binding on every consumer: the
|
|
13
|
+
* ONE current-state authority for a task is meta.json's status plus the
|
|
14
|
+
* turn-end file's recorded exit code. Currency is never derived from the
|
|
15
|
+
* tail of an event or status log, and the turn-end file is a completion
|
|
16
|
+
* NOTIFICATION, not the task's state: a missing turn-end never means
|
|
17
|
+
* success (tuition T-002).
|
|
18
|
+
*
|
|
19
|
+
* The task directory deliberately sits OUTSIDE the worktree (FM-059), so
|
|
20
|
+
* the pool's dirty check never needs an exemption list for the kernel's
|
|
21
|
+
* own injected files. That invariant is absolute: nothing this phase
|
|
22
|
+
* writes may ever land inside <fleet>/worktrees/<id>.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* OPEN NOTHING WHOSE TYPE HAS NOT BEEN ESTABLISHED (fix round 4, CR-520).
|
|
26
|
+
*
|
|
27
|
+
* THE MECHANISM AND ITS RULE. Opening a path is not a total operation.
|
|
28
|
+
* open(2) on a named pipe with no peer BLOCKS IN THE KERNEL, for reading
|
|
29
|
+
* and for writing, until a peer appears. A block is not an exception, so
|
|
30
|
+
* no try/catch sees it, no "this function never raises" reasoning touches
|
|
31
|
+
* it, and no error classification reaches it. Every process that reaches
|
|
32
|
+
* such an open stops forever with no output at all.
|
|
33
|
+
*
|
|
34
|
+
* This project has now paid for that mechanism three times in one phase:
|
|
35
|
+
* once at tasks/<id>/meta.json (delta review NEW-2), then on six further
|
|
36
|
+
* paths after the first fix was applied at ONE CALL SITE instead of at the
|
|
37
|
+
* read (CR-520), then on four more this round found by deriving the
|
|
38
|
+
* inventory again rather than inheriting it. The lesson recorded in
|
|
39
|
+
* tuition T-005 is that a rule fixed at a call site does not travel; the
|
|
40
|
+
* rule has to be a property of the operation.
|
|
41
|
+
*
|
|
42
|
+
* So the probe lives HERE, in the readers and in one classifier, and every
|
|
43
|
+
* caller is protected by construction rather than by remembering:
|
|
44
|
+
*
|
|
45
|
+
* - lstat the path (the link itself), then stat (what it resolves to),
|
|
46
|
+
* and open ONLY when that is a regular file;
|
|
47
|
+
* - a directory, FIFO, socket, device node, or a symlink resolving to
|
|
48
|
+
* any of those is classified WITHOUT being opened;
|
|
49
|
+
* - nothing at the path is not an error, because absence is the normal
|
|
50
|
+
* transient shape of most of this kernel's state files.
|
|
51
|
+
*
|
|
52
|
+
* WHY THIS LIVES IN src/task.ts. It is a general filesystem rule and not a
|
|
53
|
+
* task rule, and a dedicated module would be its right home. This module
|
|
54
|
+
* is the lowest one in the import graph that the fix round authorized to
|
|
55
|
+
* touch (src/liveness.ts imports it, src/watcher.ts imports it, and it
|
|
56
|
+
* imports neither), and it already carries one cross-cutting helper for
|
|
57
|
+
* the same reason (runStep, below). Moving both to their own module is
|
|
58
|
+
* recorded as an M2 item rather than done here without authorization.
|
|
59
|
+
*
|
|
60
|
+
* RESIDUAL, stated rather than papered over: the probe and the open are
|
|
61
|
+
* two syscalls, so a path that changes type between them can still be
|
|
62
|
+
* opened as something other than a regular file. Closing that needs
|
|
63
|
+
* open(O_NONBLOCK) followed by fstat, which Node's synchronous fs API
|
|
64
|
+
* does not expose for reads. Nothing in this kernel writes that state, and
|
|
65
|
+
* the window is now the only way to reach the block rather than the
|
|
66
|
+
* default path to it.
|
|
67
|
+
*/
|
|
68
|
+
export type EntryClass =
|
|
69
|
+
/** Nothing at the path. */
|
|
70
|
+
{
|
|
71
|
+
kind: "absent";
|
|
72
|
+
}
|
|
73
|
+
/** A link is there and resolves to nothing: it exists, and it is empty of evidence. */
|
|
74
|
+
| {
|
|
75
|
+
kind: "dangling";
|
|
76
|
+
}
|
|
77
|
+
/** Safe to open. */
|
|
78
|
+
| {
|
|
79
|
+
kind: "regular";
|
|
80
|
+
}
|
|
81
|
+
/** Present, and opening it is not safe: never opened, always named. */
|
|
82
|
+
| {
|
|
83
|
+
kind: "irregular";
|
|
84
|
+
reason: string;
|
|
85
|
+
}
|
|
86
|
+
/** Neither lstat nor stat could answer the question. */
|
|
87
|
+
| {
|
|
88
|
+
kind: "unexaminable";
|
|
89
|
+
reason: string;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* THE ONE ANSWER TO "may this path be opened". Every reader and every
|
|
93
|
+
* writer of a path this kernel does not itself guarantee to be a regular
|
|
94
|
+
* file goes through this, so there is one implementation of the question
|
|
95
|
+
* and not one per call site.
|
|
96
|
+
*/
|
|
97
|
+
export declare function classifyEntry(path: string): EntryClass;
|
|
98
|
+
/**
|
|
99
|
+
* Refuse an open-for-WRITE of a path that is not a regular file. The
|
|
100
|
+
* hazard is symmetric: open(2) for writing on a FIFO with no reader blocks
|
|
101
|
+
* exactly as reading one with no writer does, so a staged write and an
|
|
102
|
+
* append are as dangerous as a read. Returns the reason, or undefined when
|
|
103
|
+
* the path may be opened (absent included: creating it is the point).
|
|
104
|
+
*/
|
|
105
|
+
export declare function refuseOpenForWrite(path: string): string | undefined;
|
|
106
|
+
/** What a guarded read of a possibly-absent path produced. */
|
|
107
|
+
export type RegularRead = {
|
|
108
|
+
kind: "read";
|
|
109
|
+
body: string;
|
|
110
|
+
} | {
|
|
111
|
+
kind: "absent";
|
|
112
|
+
}
|
|
113
|
+
/** Present and not readable, with a reason naming the path. */
|
|
114
|
+
| {
|
|
115
|
+
kind: "refused";
|
|
116
|
+
reason: string;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* THE ONE READ of a file that might not be there and might not be a file.
|
|
120
|
+
* src/task.ts, src/liveness.ts, src/watcher.ts and src/commands/doctor.ts
|
|
121
|
+
* all read fleet state through this, so "probe before open" is a property
|
|
122
|
+
* of the read and cannot be forgotten by a new caller.
|
|
123
|
+
*/
|
|
124
|
+
export declare function readRegularFileIfPresent(path: string): RegularRead;
|
|
125
|
+
export type TaskShape = "ship" | "scout";
|
|
126
|
+
export type TaskStatus = "open" | "closed";
|
|
127
|
+
export declare const TASK_SHAPES: readonly TaskShape[];
|
|
128
|
+
/**
|
|
129
|
+
* Task meta (the plan's field set, M1-P4 step 1).
|
|
130
|
+
*
|
|
131
|
+
* - id: the task id, a safe path segment (pool's TASK_ID_PATTERN).
|
|
132
|
+
* - project: absolute path of the project clone the worktree came from.
|
|
133
|
+
* - shape: ship or scout; it selects teardown's refusal rules.
|
|
134
|
+
* - branch: the TASK branch (task/<id>), the branch teardown judges for
|
|
135
|
+
* landedness. Note the deliberate naming difference from the pool
|
|
136
|
+
* record, whose "branch" is the project's default branch and whose
|
|
137
|
+
* "branchName" is this one; meta records the branch the task works on.
|
|
138
|
+
* - worktree: absolute path of the task worktree.
|
|
139
|
+
* - baseSha: the fetched base SHA pool create emitted (EXT-F-03).
|
|
140
|
+
* - baseOffline: provenance, COPIED from the pool record's offline field
|
|
141
|
+
* and never recomputed (PR-212). It is true only when the fetch failed
|
|
142
|
+
* and --offline authorized falling back to the last fetched
|
|
143
|
+
* remote-tracking SHA. A spawn that recomputed it from its own flags
|
|
144
|
+
* would report a fetched base as offline whenever a contended fetch
|
|
145
|
+
* succeeded on retry, which is exactly the provenance inversion V-2
|
|
146
|
+
* produced in M1-P3.
|
|
147
|
+
* - status: open at spawn, closed by a successful teardown.
|
|
148
|
+
* - createdAt: ISO-8601 timestamp of the spawn.
|
|
149
|
+
*/
|
|
150
|
+
export interface TaskMeta {
|
|
151
|
+
id: string;
|
|
152
|
+
project: string;
|
|
153
|
+
shape: TaskShape;
|
|
154
|
+
branch: string;
|
|
155
|
+
worktree: string;
|
|
156
|
+
baseSha: string;
|
|
157
|
+
baseOffline: boolean;
|
|
158
|
+
status: TaskStatus;
|
|
159
|
+
createdAt: string;
|
|
160
|
+
}
|
|
161
|
+
export declare function taskDir(fleet: Fleet, taskId: string): string;
|
|
162
|
+
export declare function metaPath(fleet: Fleet, taskId: string): string;
|
|
163
|
+
export declare function briefPath(fleet: Fleet, taskId: string): string;
|
|
164
|
+
export declare function turnEndPath(fleet: Fleet, taskId: string): string;
|
|
165
|
+
export declare function executorRecordPath(fleet: Fleet, taskId: string): string;
|
|
166
|
+
export declare function reportPath(fleet: Fleet, taskId: string): string;
|
|
167
|
+
/** Serialize meta the way every kernel JSON state file is written. */
|
|
168
|
+
export declare function renderTaskMeta(meta: TaskMeta): string;
|
|
169
|
+
export declare function writeTaskMeta(fleet: Fleet, meta: TaskMeta): void;
|
|
170
|
+
/**
|
|
171
|
+
* Read meta.json, or undefined when it is absent, is not a regular file,
|
|
172
|
+
* or does not parse. All three mean the same thing to every caller: this
|
|
173
|
+
* is not a readable record, and it is not evidence that the task finished.
|
|
174
|
+
*
|
|
175
|
+
* The type probe is INSIDE this function and not in front of one of its
|
|
176
|
+
* callers (CR-520, CR-521). There is exactly one implementation of "read a
|
|
177
|
+
* task record", every caller of it is protected, and adding a caller
|
|
178
|
+
* cannot reopen the hole: src/teardown.ts reaches this directly, without
|
|
179
|
+
* going through the liveness classifier, and a named pipe here used to
|
|
180
|
+
* hang it forever.
|
|
181
|
+
*/
|
|
182
|
+
export declare function readTaskMeta(fleet: Fleet, taskId: string): TaskMeta | undefined;
|
|
183
|
+
/** Set meta.json status (teardown's last step; C-1's state authority). */
|
|
184
|
+
export declare function setTaskStatus(fleet: Fleet, meta: TaskMeta, status: TaskStatus): void;
|
|
185
|
+
export type GuardResult = {
|
|
186
|
+
ok: true;
|
|
187
|
+
} | {
|
|
188
|
+
ok: false;
|
|
189
|
+
reason: string;
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Holdership guard for the task-mutating commands (PR-203), shared by
|
|
193
|
+
* spawn and teardown so there is exactly one implementation of the rule.
|
|
194
|
+
* It lives here rather than in a guard module of its own because this
|
|
195
|
+
* phase's files-to-touch list has no shared-guard file and the rule is a
|
|
196
|
+
* precondition of every task mutation.
|
|
197
|
+
*
|
|
198
|
+
* The transport is M1-P3's, unchanged and not re-invented: lock acquire
|
|
199
|
+
* PRINTS the opaque holderId, the operator carries it, and here it
|
|
200
|
+
* arrives as the TIPHYS_HOLDER_ID environment variable. No second
|
|
201
|
+
* identity mechanism exists, and nothing about the holder is derived
|
|
202
|
+
* from the running program (plan constraint C-2): this reads the lease
|
|
203
|
+
* FILE and nothing else.
|
|
204
|
+
*
|
|
205
|
+
* With no lease file present the command proceeds (the plan's M1 test
|
|
206
|
+
* contexts). A lease that exists but is unreadable, expired, or held by
|
|
207
|
+
* a different holder refuses: fail closed, because every one of those
|
|
208
|
+
* states means this caller cannot prove it is the one orchestrator.
|
|
209
|
+
*/
|
|
210
|
+
export declare function checkHoldership(fleet: Fleet): GuardResult;
|
|
211
|
+
/** True when the task directory already exists (spawn's rollback scope). */
|
|
212
|
+
export declare function taskDirExists(fleet: Fleet, taskId: string): boolean;
|
|
213
|
+
/**
|
|
214
|
+
* True when tasks/<id>/ already holds a previous incarnation of the task
|
|
215
|
+
* id, which spawn refuses (CR-301).
|
|
216
|
+
*
|
|
217
|
+
* The task directory is the DURABLE record: teardown removes the worktree
|
|
218
|
+
* and the pool record but deliberately leaves tasks/<id>/ behind, so the
|
|
219
|
+
* id is free from the pool's point of view and occupied from the task
|
|
220
|
+
* state's. Spawning into it would overwrite the closed task's records,
|
|
221
|
+
* hand the launch-failure rollback files it did not create, and leave the
|
|
222
|
+
* previous incarnation's turn-end file readable while the new
|
|
223
|
+
* incarnation's meta says open, which is a completion that did not happen
|
|
224
|
+
* sitting under the C-1 state authority.
|
|
225
|
+
*
|
|
226
|
+
* A path that exists but is not a directory counts as occupied too: it is
|
|
227
|
+
* not a state this kernel may write into, and refusing costs the operator
|
|
228
|
+
* one rename while guessing could cost the record.
|
|
229
|
+
*/
|
|
230
|
+
export declare function taskDirOccupied(fleet: Fleet, taskId: string): boolean;
|
|
231
|
+
/**
|
|
232
|
+
* Collapse captured git or error output to ONE line (CR-303). Plan step 5 ends
|
|
233
|
+
* "every refusal is exit nonzero plus a single reason line", and git's
|
|
234
|
+
* own stderr is routinely five lines, so any interpolation of it must be
|
|
235
|
+
* flattened rather than trusted to be short. The M1-P6 harness reads
|
|
236
|
+
* these reason lines as evidence.
|
|
237
|
+
*/
|
|
238
|
+
export declare function singleLine(text: string): string;
|
|
239
|
+
export type StepResult<T> = {
|
|
240
|
+
ok: true;
|
|
241
|
+
value: T;
|
|
242
|
+
} | {
|
|
243
|
+
ok: false;
|
|
244
|
+
reason: string;
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Run one step that may signal failure by THROWING, and fold a raised
|
|
248
|
+
* error into the same ok/reason shape every other step in spawn and
|
|
249
|
+
* teardown returns (F-1, F-2).
|
|
250
|
+
*
|
|
251
|
+
* This exists because the modules were written as a result type end to
|
|
252
|
+
* end while the Node fs calls underneath them are not: writeFileSync and
|
|
253
|
+
* mkdirSync raise. Every returned failure was handled correctly and every
|
|
254
|
+
* THROWN one walked straight past the handler, out of the command, and
|
|
255
|
+
* onto stderr as a stack trace, taking spawn's rollback and teardown's
|
|
256
|
+
* state update with it. Wrapping is therefore not defensive decoration
|
|
257
|
+
* for a state M1 never reaches: it is the difference between a rollback
|
|
258
|
+
* that runs and an orphaned worktree, and between a task marked closed
|
|
259
|
+
* and a meta.json that lies about a worktree that is already gone.
|
|
260
|
+
*
|
|
261
|
+
* It never swallows: the caller still gets a reason naming the step, and
|
|
262
|
+
* still decides whether to roll back, report a partial failure, or refuse.
|
|
263
|
+
*/
|
|
264
|
+
export declare function runStep<T>(what: string, step: () => T): StepResult<T>;
|
package/dist/src/task.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { leaseStatus } from "./lock.js";
|
|
4
|
+
function describeType(stats) {
|
|
5
|
+
if (stats.isDirectory()) {
|
|
6
|
+
return "a directory";
|
|
7
|
+
}
|
|
8
|
+
if (stats.isFIFO()) {
|
|
9
|
+
return "a named pipe";
|
|
10
|
+
}
|
|
11
|
+
if (stats.isSocket()) {
|
|
12
|
+
return "a socket";
|
|
13
|
+
}
|
|
14
|
+
if (stats.isCharacterDevice()) {
|
|
15
|
+
return "a character device";
|
|
16
|
+
}
|
|
17
|
+
if (stats.isBlockDevice()) {
|
|
18
|
+
return "a block device";
|
|
19
|
+
}
|
|
20
|
+
return "an entry of an unrecognized type";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* THE ONE ANSWER TO "may this path be opened". Every reader and every
|
|
24
|
+
* writer of a path this kernel does not itself guarantee to be a regular
|
|
25
|
+
* file goes through this, so there is one implementation of the question
|
|
26
|
+
* and not one per call site.
|
|
27
|
+
*/
|
|
28
|
+
export function classifyEntry(path) {
|
|
29
|
+
try {
|
|
30
|
+
lstatSync(path);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code === "ENOENT") {
|
|
34
|
+
return { kind: "absent" };
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
kind: "unexaminable",
|
|
38
|
+
reason: `${path} could not be examined: ${String(error)}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
let stats;
|
|
42
|
+
try {
|
|
43
|
+
stats = statSync(path);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === "ENOENT") {
|
|
47
|
+
return { kind: "dangling" };
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
kind: "unexaminable",
|
|
51
|
+
reason: `${path} could not be examined: ${String(error)}`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (stats.isFile()) {
|
|
55
|
+
return { kind: "regular" };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
kind: "irregular",
|
|
59
|
+
reason: `${path} is ${describeType(stats)}, not a regular file, so it was not opened`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Refuse an open-for-WRITE of a path that is not a regular file. The
|
|
64
|
+
* hazard is symmetric: open(2) for writing on a FIFO with no reader blocks
|
|
65
|
+
* exactly as reading one with no writer does, so a staged write and an
|
|
66
|
+
* append are as dangerous as a read. Returns the reason, or undefined when
|
|
67
|
+
* the path may be opened (absent included: creating it is the point).
|
|
68
|
+
*/
|
|
69
|
+
export function refuseOpenForWrite(path) {
|
|
70
|
+
const entry = classifyEntry(path);
|
|
71
|
+
if (entry.kind === "irregular" || entry.kind === "unexaminable") {
|
|
72
|
+
return entry.reason;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* THE ONE READ of a file that might not be there and might not be a file.
|
|
78
|
+
* src/task.ts, src/liveness.ts, src/watcher.ts and src/commands/doctor.ts
|
|
79
|
+
* all read fleet state through this, so "probe before open" is a property
|
|
80
|
+
* of the read and cannot be forgotten by a new caller.
|
|
81
|
+
*/
|
|
82
|
+
export function readRegularFileIfPresent(path) {
|
|
83
|
+
const entry = classifyEntry(path);
|
|
84
|
+
if (entry.kind === "absent" || entry.kind === "dangling") {
|
|
85
|
+
return { kind: "absent" };
|
|
86
|
+
}
|
|
87
|
+
if (entry.kind === "irregular" || entry.kind === "unexaminable") {
|
|
88
|
+
return { kind: "refused", reason: entry.reason };
|
|
89
|
+
}
|
|
90
|
+
let body;
|
|
91
|
+
try {
|
|
92
|
+
body = readFileSync(path, "utf8");
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code === "ENOENT") {
|
|
96
|
+
// Removed between the probe and the read.
|
|
97
|
+
return { kind: "absent" };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
kind: "refused",
|
|
101
|
+
reason: `${path} could not be read: ${String(error)}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { kind: "read", body };
|
|
105
|
+
}
|
|
106
|
+
export const TASK_SHAPES = ["ship", "scout"];
|
|
107
|
+
export function taskDir(fleet, taskId) {
|
|
108
|
+
return join(fleet.tasksDir, taskId);
|
|
109
|
+
}
|
|
110
|
+
export function metaPath(fleet, taskId) {
|
|
111
|
+
return join(taskDir(fleet, taskId), "meta.json");
|
|
112
|
+
}
|
|
113
|
+
export function briefPath(fleet, taskId) {
|
|
114
|
+
return join(taskDir(fleet, taskId), "brief.md");
|
|
115
|
+
}
|
|
116
|
+
export function turnEndPath(fleet, taskId) {
|
|
117
|
+
return join(taskDir(fleet, taskId), "turn-end");
|
|
118
|
+
}
|
|
119
|
+
export function executorRecordPath(fleet, taskId) {
|
|
120
|
+
return join(taskDir(fleet, taskId), "executor.json");
|
|
121
|
+
}
|
|
122
|
+
export function reportPath(fleet, taskId) {
|
|
123
|
+
return join(taskDir(fleet, taskId), "report.md");
|
|
124
|
+
}
|
|
125
|
+
/** Serialize meta the way every kernel JSON state file is written. */
|
|
126
|
+
export function renderTaskMeta(meta) {
|
|
127
|
+
return `${JSON.stringify(meta, null, 2)}\n`;
|
|
128
|
+
}
|
|
129
|
+
export function writeTaskMeta(fleet, meta) {
|
|
130
|
+
writeFileSync(metaPath(fleet, meta.id), renderTaskMeta(meta));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Read meta.json, or undefined when it is absent, is not a regular file,
|
|
134
|
+
* or does not parse. All three mean the same thing to every caller: this
|
|
135
|
+
* is not a readable record, and it is not evidence that the task finished.
|
|
136
|
+
*
|
|
137
|
+
* The type probe is INSIDE this function and not in front of one of its
|
|
138
|
+
* callers (CR-520, CR-521). There is exactly one implementation of "read a
|
|
139
|
+
* task record", every caller of it is protected, and adding a caller
|
|
140
|
+
* cannot reopen the hole: src/teardown.ts reaches this directly, without
|
|
141
|
+
* going through the liveness classifier, and a named pipe here used to
|
|
142
|
+
* hang it forever.
|
|
143
|
+
*/
|
|
144
|
+
export function readTaskMeta(fleet, taskId) {
|
|
145
|
+
const read = readRegularFileIfPresent(metaPath(fleet, taskId));
|
|
146
|
+
if (read.kind !== "read") {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = JSON.parse(read.body);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
const candidate = parsed;
|
|
157
|
+
if (typeof candidate.id !== "string" ||
|
|
158
|
+
typeof candidate.project !== "string" ||
|
|
159
|
+
(candidate.shape !== "ship" && candidate.shape !== "scout") ||
|
|
160
|
+
typeof candidate.branch !== "string" ||
|
|
161
|
+
typeof candidate.worktree !== "string" ||
|
|
162
|
+
typeof candidate.baseSha !== "string" ||
|
|
163
|
+
typeof candidate.baseOffline !== "boolean" ||
|
|
164
|
+
(candidate.status !== "open" && candidate.status !== "closed") ||
|
|
165
|
+
typeof candidate.createdAt !== "string") {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
return candidate;
|
|
169
|
+
}
|
|
170
|
+
/** Set meta.json status (teardown's last step; C-1's state authority). */
|
|
171
|
+
export function setTaskStatus(fleet, meta, status) {
|
|
172
|
+
writeTaskMeta(fleet, { ...meta, status });
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Holdership guard for the task-mutating commands (PR-203), shared by
|
|
176
|
+
* spawn and teardown so there is exactly one implementation of the rule.
|
|
177
|
+
* It lives here rather than in a guard module of its own because this
|
|
178
|
+
* phase's files-to-touch list has no shared-guard file and the rule is a
|
|
179
|
+
* precondition of every task mutation.
|
|
180
|
+
*
|
|
181
|
+
* The transport is M1-P3's, unchanged and not re-invented: lock acquire
|
|
182
|
+
* PRINTS the opaque holderId, the operator carries it, and here it
|
|
183
|
+
* arrives as the TIPHYS_HOLDER_ID environment variable. No second
|
|
184
|
+
* identity mechanism exists, and nothing about the holder is derived
|
|
185
|
+
* from the running program (plan constraint C-2): this reads the lease
|
|
186
|
+
* FILE and nothing else.
|
|
187
|
+
*
|
|
188
|
+
* With no lease file present the command proceeds (the plan's M1 test
|
|
189
|
+
* contexts). A lease that exists but is unreadable, expired, or held by
|
|
190
|
+
* a different holder refuses: fail closed, because every one of those
|
|
191
|
+
* states means this caller cannot prove it is the one orchestrator.
|
|
192
|
+
*/
|
|
193
|
+
export function checkHoldership(fleet) {
|
|
194
|
+
const status = leaseStatus(fleet.lockPath);
|
|
195
|
+
if (status.state === "free") {
|
|
196
|
+
return { ok: true };
|
|
197
|
+
}
|
|
198
|
+
if (status.state === "corrupt") {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
reason: `lease file ${fleet.lockPath} exists but does not parse; refusing without provable holdership`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const holder = process.env.TIPHYS_HOLDER_ID;
|
|
205
|
+
if (status.state === "expired") {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reason: `lease ${fleet.lockPath} expired ${status.lease.expiresAt} (holder ` +
|
|
209
|
+
`${status.lease.holderId}); re-acquire or take over before mutating tasks`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (holder === undefined || holder === "") {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
reason: `lease ${fleet.lockPath} is held by ${status.lease.holderId} and ` +
|
|
216
|
+
`TIPHYS_HOLDER_ID is not set; set it to the holder id lock acquire printed`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (holder !== status.lease.holderId) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
reason: `lease ${fleet.lockPath} is held by ${status.lease.holderId}, not by ` +
|
|
223
|
+
`TIPHYS_HOLDER_ID ${holder}`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return { ok: true };
|
|
227
|
+
}
|
|
228
|
+
/** True when the task directory already exists (spawn's rollback scope). */
|
|
229
|
+
export function taskDirExists(fleet, taskId) {
|
|
230
|
+
return existsSync(taskDir(fleet, taskId));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* True when tasks/<id>/ already holds a previous incarnation of the task
|
|
234
|
+
* id, which spawn refuses (CR-301).
|
|
235
|
+
*
|
|
236
|
+
* The task directory is the DURABLE record: teardown removes the worktree
|
|
237
|
+
* and the pool record but deliberately leaves tasks/<id>/ behind, so the
|
|
238
|
+
* id is free from the pool's point of view and occupied from the task
|
|
239
|
+
* state's. Spawning into it would overwrite the closed task's records,
|
|
240
|
+
* hand the launch-failure rollback files it did not create, and leave the
|
|
241
|
+
* previous incarnation's turn-end file readable while the new
|
|
242
|
+
* incarnation's meta says open, which is a completion that did not happen
|
|
243
|
+
* sitting under the C-1 state authority.
|
|
244
|
+
*
|
|
245
|
+
* A path that exists but is not a directory counts as occupied too: it is
|
|
246
|
+
* not a state this kernel may write into, and refusing costs the operator
|
|
247
|
+
* one rename while guessing could cost the record.
|
|
248
|
+
*/
|
|
249
|
+
export function taskDirOccupied(fleet, taskId) {
|
|
250
|
+
const dir = taskDir(fleet, taskId);
|
|
251
|
+
if (!existsSync(dir)) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
if (!statSync(dir).isDirectory()) {
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
return readdirSync(dir).length > 0;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Unreadable is not empty: fail closed.
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Collapse captured git or error output to ONE line (CR-303). Plan step 5 ends
|
|
267
|
+
* "every refusal is exit nonzero plus a single reason line", and git's
|
|
268
|
+
* own stderr is routinely five lines, so any interpolation of it must be
|
|
269
|
+
* flattened rather than trusted to be short. The M1-P6 harness reads
|
|
270
|
+
* these reason lines as evidence.
|
|
271
|
+
*/
|
|
272
|
+
export function singleLine(text) {
|
|
273
|
+
return text
|
|
274
|
+
.split("\n")
|
|
275
|
+
.map((line) => line.trim())
|
|
276
|
+
.filter((line) => line !== "")
|
|
277
|
+
.join("; ");
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Run one step that may signal failure by THROWING, and fold a raised
|
|
281
|
+
* error into the same ok/reason shape every other step in spawn and
|
|
282
|
+
* teardown returns (F-1, F-2).
|
|
283
|
+
*
|
|
284
|
+
* This exists because the modules were written as a result type end to
|
|
285
|
+
* end while the Node fs calls underneath them are not: writeFileSync and
|
|
286
|
+
* mkdirSync raise. Every returned failure was handled correctly and every
|
|
287
|
+
* THROWN one walked straight past the handler, out of the command, and
|
|
288
|
+
* onto stderr as a stack trace, taking spawn's rollback and teardown's
|
|
289
|
+
* state update with it. Wrapping is therefore not defensive decoration
|
|
290
|
+
* for a state M1 never reaches: it is the difference between a rollback
|
|
291
|
+
* that runs and an orphaned worktree, and between a task marked closed
|
|
292
|
+
* and a meta.json that lies about a worktree that is already gone.
|
|
293
|
+
*
|
|
294
|
+
* It never swallows: the caller still gets a reason naming the step, and
|
|
295
|
+
* still decides whether to roll back, report a partial failure, or refuse.
|
|
296
|
+
*/
|
|
297
|
+
export function runStep(what, step) {
|
|
298
|
+
try {
|
|
299
|
+
return { ok: true, value: step() };
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
303
|
+
return { ok: false, reason: `${what} failed: ${detail}` };
|
|
304
|
+
}
|
|
305
|
+
}
|