runlist 0.0.0 → 0.94.1
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 +1114 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +292 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +794 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
package/src/pickup.mjs
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { currentProcessOwner, mutateFileSet, processOwnerLiveness, processStartIdentity, replaceSnapshot, snapshotFile, withPathLocks } from './atomic-mutation.mjs';
|
|
7
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
8
|
+
import { asString, hostSessionId, relTime } from './util.mjs';
|
|
9
|
+
import { readEnv, stateDir } from './naming.mjs';
|
|
10
|
+
|
|
11
|
+
export const OWNERSHIP_SCHEMA = 2;
|
|
12
|
+
export const HOOK_DELIVERY_LEASE_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
export function authoritativeSessionId(env = process.env) {
|
|
15
|
+
const id = hostSessionId(env);
|
|
16
|
+
if (id) return id;
|
|
17
|
+
// Name the host when we can recognize it. The generic "set RUNLIST_SESSION_ID"
|
|
18
|
+
// is the fallback of last resort, and a poor one to reach for first: exported
|
|
19
|
+
// from a shell profile it gives every session in that shell ONE id, which is
|
|
20
|
+
// the collision the ownership record exists to prevent.
|
|
21
|
+
const host = env.OPENCODE || env.OPENCODE_PID ? 'opencode' : null;
|
|
22
|
+
throw new Error(host
|
|
23
|
+
? `No authoritative session identity. Run \`runlist install ${host}\` to give each ${host} session its own, or set RUNLIST_SESSION_ID for this shell.`
|
|
24
|
+
: 'No authoritative session identity. Set RUNLIST_SESSION_ID for this shell or host session, or see `runlist install` for supported hosts.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function availableSessionId(env = process.env) {
|
|
28
|
+
try { return authoritativeSessionId(env); } catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The process that OWNS the session, not the one taking the claim. `runlist` exits
|
|
32
|
+
// within the second, so its own pid is always dead a moment later and can say
|
|
33
|
+
// nothing about whether the session still exists; the agent harness that spawned
|
|
34
|
+
// it is what outlives the command. Recording that process is what lets a claim
|
|
35
|
+
// answer "is its owner still there?" with the liveness check runlist already has,
|
|
36
|
+
// instead of an age threshold that cannot tell a three-day-dead session from a
|
|
37
|
+
// long-running one. Absent (a plain terminal, an unknown harness) is not an
|
|
38
|
+
// error — it yields null, which reads as 'unverifiable' and never auto-reclaims.
|
|
39
|
+
// `OPENCODE_PID` is the OpenCode server process, which is exactly that harness:
|
|
40
|
+
// it hosts the session and outlives every tool shell it spawns.
|
|
41
|
+
export function sessionProcessOwner(env = process.env) {
|
|
42
|
+
const raw = (readEnv('SESSION_PID', env) ?? env.CLAUDE_PID ?? env.OPENCODE_PID)?.trim();
|
|
43
|
+
const pid = Number(raw);
|
|
44
|
+
if (!raw || !Number.isInteger(pid) || pid <= 0) return null;
|
|
45
|
+
return {
|
|
46
|
+
pid,
|
|
47
|
+
hostname: os.hostname(),
|
|
48
|
+
processStartIdentity: processStartIdentity(pid),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Only a claim that names a process we can probe, on this host, can be judged.
|
|
53
|
+
// Everything else — a record written before sessionOwner existed, a claim from a
|
|
54
|
+
// plain terminal, another machine's claim on a shared filesystem — is
|
|
55
|
+
// 'unverifiable' and is treated as live, so a takeover stays an explicit --force.
|
|
56
|
+
export function ownershipLiveness(ownership) {
|
|
57
|
+
if (!ownership || ownership.corrupt || !ownership.sessionOwner) return 'unverifiable';
|
|
58
|
+
return processOwnerLiveness(ownership.sessionOwner);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sameIdentity(left, right, fs = { statSync }) {
|
|
62
|
+
try {
|
|
63
|
+
const a = fs.statSync(left, { bigint: true });
|
|
64
|
+
const b = fs.statSync(right, { bigint: true });
|
|
65
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
66
|
+
} catch { return false; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// realpath may preserve caller-provided case on case-insensitive filesystems.
|
|
70
|
+
// Recover the directory entry spelling at every level. The resulting identity
|
|
71
|
+
// survives atomic replacement because it is path-based, not keyed by inode.
|
|
72
|
+
export function canonicalizePathEntrySpelling(input, fs = { readdirSync, statSync }) {
|
|
73
|
+
const absolute = path.resolve(input);
|
|
74
|
+
const parsed = path.parse(absolute);
|
|
75
|
+
const segments = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
76
|
+
let current = parsed.root;
|
|
77
|
+
for (const segment of segments) {
|
|
78
|
+
const requested = path.join(current, segment);
|
|
79
|
+
let entries;
|
|
80
|
+
try { entries = fs.readdirSync(current); } catch { return absolute; }
|
|
81
|
+
const exact = entries.find(entry => entry === segment);
|
|
82
|
+
const actual = exact ?? entries.find(entry => sameIdentity(path.join(current, entry), requested, fs));
|
|
83
|
+
if (!actual) return absolute;
|
|
84
|
+
current = path.join(current, actual);
|
|
85
|
+
}
|
|
86
|
+
return current;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function classifyPlanPickup(facts) {
|
|
90
|
+
const {
|
|
91
|
+
type, status, validStatuses, startableStatuses, terminalStatuses,
|
|
92
|
+
archiveStatuses, physicallyArchived, ownership, sessionId, malformed,
|
|
93
|
+
// Defaulted rather than required: a caller with no view of the owning
|
|
94
|
+
// process (and every existing test) gets the conservative answer, which is
|
|
95
|
+
// that the owner might still be there.
|
|
96
|
+
ownerLiveness = 'unverifiable',
|
|
97
|
+
} = facts;
|
|
98
|
+
if (malformed) return { kind: 'malformed', pickupable: false };
|
|
99
|
+
if (type !== 'plan') return { kind: 'wrong-type', pickupable: false };
|
|
100
|
+
if (!status || !validStatuses?.has(status)) return { kind: 'unconfigured-status', pickupable: false };
|
|
101
|
+
if (physicallyArchived) return { kind: 'physical-archive', pickupable: false };
|
|
102
|
+
if (archiveStatuses?.has(status) || terminalStatuses?.has(status)) return { kind: 'terminal', pickupable: false };
|
|
103
|
+
if (ownership?.corrupt) return { kind: 'ownership-corrupt', pickupable: false };
|
|
104
|
+
if (ownership?.state === 'owned' && ownership.sessionId !== sessionId && ownerLiveness !== 'dead') {
|
|
105
|
+
return { kind: 'busy', pickupable: false, owner: ownership.sessionId };
|
|
106
|
+
}
|
|
107
|
+
if (status === 'in-session') {
|
|
108
|
+
// `resume` means picking my own claim back up. A record left by a session
|
|
109
|
+
// whose process is gone reached here because it is no longer busy, and
|
|
110
|
+
// taking that over is an adopt — the same disposition as a plan sitting
|
|
111
|
+
// `in-session` with no record at all.
|
|
112
|
+
if (ownership?.state === 'owned' && ownership.sessionId === sessionId) return { kind: 'resume', pickupable: true };
|
|
113
|
+
return { kind: 'adopt', pickupable: true };
|
|
114
|
+
}
|
|
115
|
+
if (startableStatuses?.has(status)) return { kind: 'start', pickupable: true };
|
|
116
|
+
return { kind: 'parked', pickupable: false };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ownershipRoot(config) {
|
|
120
|
+
return path.join(stateDir(config.repoRoot), 'ownership');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function canonicalPlanIdentity(filePath, config) {
|
|
124
|
+
const authorized = authorizeManagedSource(filePath, config, { kind: 'Ownership plan source' });
|
|
125
|
+
const resolved = process.platform === 'win32'
|
|
126
|
+
? path.join(realpathSync(path.dirname(authorized.path)), path.basename(authorized.path))
|
|
127
|
+
: canonicalizePathEntrySpelling(realpathSync(authorized.path));
|
|
128
|
+
const canonicalPath = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
129
|
+
let repoPath = path.relative(config.repoRoot, authorized.path);
|
|
130
|
+
if (process.platform === 'win32') repoPath = repoPath.toLowerCase();
|
|
131
|
+
else {
|
|
132
|
+
const displayRoot = canonicalizePathEntrySpelling(authorized.root.lexicalPath);
|
|
133
|
+
const managedDisplayPath = canonicalizePathEntrySpelling(authorized.path);
|
|
134
|
+
repoPath = path.relative(config.repoRoot, path.join(authorized.root.lexicalPath, path.relative(displayRoot, managedDisplayPath)));
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
canonicalPath,
|
|
138
|
+
key: createHash('sha256').update(canonicalPath).digest('hex'),
|
|
139
|
+
repoPath: repoPath.split(path.sep).join('/'),
|
|
140
|
+
managedPath: authorized.path,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function plannedPlanIdentity(filePath, config) {
|
|
145
|
+
const absolute = path.resolve(filePath);
|
|
146
|
+
let parent = path.dirname(absolute);
|
|
147
|
+
const suffix = [path.basename(absolute)];
|
|
148
|
+
while (!existsSync(parent)) {
|
|
149
|
+
suffix.unshift(path.basename(parent));
|
|
150
|
+
parent = path.dirname(parent);
|
|
151
|
+
}
|
|
152
|
+
const resolved = canonicalizePathEntrySpelling(path.join(realpathSync(parent), ...suffix));
|
|
153
|
+
const canonicalPath = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
154
|
+
return {
|
|
155
|
+
canonicalPath,
|
|
156
|
+
key: createHash('sha256').update(canonicalPath).digest('hex'),
|
|
157
|
+
repoPath: (process.platform === 'win32'
|
|
158
|
+
? path.relative(config.repoRoot, absolute).toLowerCase()
|
|
159
|
+
: path.relative(config.repoRoot, absolute))
|
|
160
|
+
.split(path.sep).join('/'),
|
|
161
|
+
managedPath: absolute,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function recordPathForIdentity(identity, config) {
|
|
166
|
+
return authorizeRepoGeneratedPath(path.join(ownershipRoot(config), `${identity.key}.json`), config, {
|
|
167
|
+
kind: 'Plan ownership record',
|
|
168
|
+
}).path;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function ownershipPathFor(repoPath, config) {
|
|
172
|
+
return recordPathForIdentity(canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config), config);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseOwnership(raw, recordPath) {
|
|
176
|
+
try {
|
|
177
|
+
const value = JSON.parse(raw);
|
|
178
|
+
if (value?.schema !== OWNERSHIP_SCHEMA
|
|
179
|
+
|| !['owned', 'released'].includes(value.state)
|
|
180
|
+
|| typeof value.plan !== 'string'
|
|
181
|
+
|| typeof value.canonicalPath !== 'string'
|
|
182
|
+
|| typeof value.identityKey !== 'string'
|
|
183
|
+
|| typeof value.sessionId !== 'string') throw new Error('invalid shape');
|
|
184
|
+
return { ...value, recordPath, raw, corrupt: false };
|
|
185
|
+
} catch {
|
|
186
|
+
return { corrupt: true, recordPath, raw, reason: 'invalid ownership JSON or schema' };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The bar for reclaiming another session's plan without being asked to, and it is
|
|
191
|
+
// deliberately the same bar `assertHookDeliveryReclaimable` already sets for a
|
|
192
|
+
// forced hook-delivery takeover: *demonstrably* dead. `processOwnerLiveness`
|
|
193
|
+
// answers 'dead' only for a probe that came back ESRCH on this host, or a pid
|
|
194
|
+
// whose process start-identity no longer matches the one recorded — so a reused
|
|
195
|
+
// pid reads live, another machine's claim reads unverifiable, and a record
|
|
196
|
+
// written before `sessionOwner` existed reads unverifiable. Every one of those
|
|
197
|
+
// keeps the refusal and leaves the takeover to an explicit --force. Only a
|
|
198
|
+
// process we watched go away is treated as gone.
|
|
199
|
+
function abandonedByDeadSession(ownership) {
|
|
200
|
+
return ownershipLiveness(ownership) === 'dead';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const BUSY_TAKEOVER_RECOVERY = 're-run this command with --force to take the plan over.';
|
|
204
|
+
|
|
205
|
+
// A busy refusal almost always fires on the command that would have *cleared*
|
|
206
|
+
// the wedge — `baton`, `set`, `archive` — so the message has to carry the two
|
|
207
|
+
// things that decide what to do next: how long the owner has held the claim, and
|
|
208
|
+
// the verb that takes it back. Nothing expires a session claim, so an owner
|
|
209
|
+
// three days gone looks exactly like one mid-edit; a survey of real sessions
|
|
210
|
+
// found 16 of these against 7 plans, every owner long dead, and not one of them
|
|
211
|
+
// recovered. What agents did instead was re-run the same command with the plan
|
|
212
|
+
// addressed differently (path, then slug) — reading a bare "busy in another
|
|
213
|
+
// session (<uuid>)" as "you named the plan wrong". The age is the judgement
|
|
214
|
+
// input and the recovery line is the way out; neither was there to read.
|
|
215
|
+
function planBusyError(repoPath, ownership, recovery = BUSY_TAKEOVER_RECOVERY) {
|
|
216
|
+
const since = ownership.updatedAt ?? ownership.claimedAt ?? null;
|
|
217
|
+
const age = since ? relTime(since) : null;
|
|
218
|
+
const held = age && age !== '?' ? ` since ${since} (${age})` : '';
|
|
219
|
+
return new Error(`Plan is busy in another session: ${repoPath}\n`
|
|
220
|
+
+ ` held by session ${ownership.sessionId}${held}\n`
|
|
221
|
+
+ ` If that session is gone, ${recovery}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function validateBinding(record, identity, config) {
|
|
225
|
+
if (record.corrupt) return record;
|
|
226
|
+
const expectedPath = recordPathForIdentity(identity, config);
|
|
227
|
+
if (record.recordPath !== expectedPath
|
|
228
|
+
|| record.identityKey !== identity.key
|
|
229
|
+
|| record.canonicalPath !== identity.canonicalPath
|
|
230
|
+
|| record.plan !== identity.repoPath) {
|
|
231
|
+
return { ...record, corrupt: true, reason: 'ownership record identity/path binding mismatch' };
|
|
232
|
+
}
|
|
233
|
+
return record;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Working out a plan's canonical identity lists every directory on its path,
|
|
237
|
+
// three times over, and listing plans asks it of every plan in the repo. A
|
|
238
|
+
// record can only belong to a plan whose file name it carries, so the names in
|
|
239
|
+
// the ownership folder rule most plans out first. The summary is rebuilt
|
|
240
|
+
// whenever the folder changes, and any record it cannot read turns the check
|
|
241
|
+
// off, so the full identity check still decides every case it could get wrong.
|
|
242
|
+
let ownershipNamesCache = null;
|
|
243
|
+
|
|
244
|
+
function ownershipNames(config) {
|
|
245
|
+
const dir = ownershipRoot(config);
|
|
246
|
+
let stamp;
|
|
247
|
+
try { stamp = `${dir}\0${statSync(dir, { bigint: true }).mtimeNs}`; }
|
|
248
|
+
catch { return new Set(); }
|
|
249
|
+
if (ownershipNamesCache?.stamp === stamp) return ownershipNamesCache.names;
|
|
250
|
+
let names = new Set();
|
|
251
|
+
try {
|
|
252
|
+
for (const entry of readdirSync(dir)) {
|
|
253
|
+
if (!entry.endsWith('.json')) continue;
|
|
254
|
+
const record = parseOwnership(readFileSync(path.join(dir, entry), 'utf8'), entry);
|
|
255
|
+
if (record.corrupt) { names = null; break; }
|
|
256
|
+
names.add(path.basename(record.canonicalPath).toLowerCase());
|
|
257
|
+
names.add(path.basename(record.plan).toLowerCase());
|
|
258
|
+
}
|
|
259
|
+
} catch { names = null; }
|
|
260
|
+
ownershipNamesCache = { stamp, names };
|
|
261
|
+
return names;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function mayHaveOwnershipRecord(absolutePath, config) {
|
|
265
|
+
const names = ownershipNames(config);
|
|
266
|
+
if (names === null || names.has(path.basename(absolutePath).toLowerCase())) return true;
|
|
267
|
+
try {
|
|
268
|
+
if (lstatSync(absolutePath).isSymbolicLink()) return true;
|
|
269
|
+
// A record whose fields were rewritten still sits at its identity's file
|
|
270
|
+
// name, and has to be found to be reported corrupt.
|
|
271
|
+
const guess = createHash('sha256').update(realpathSync(absolutePath)).digest('hex');
|
|
272
|
+
return existsSync(path.join(ownershipRoot(config), `${guess}.json`));
|
|
273
|
+
} catch { return true; }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function readPlanOwnership(repoPath, config) {
|
|
277
|
+
if (!mayHaveOwnershipRecord(path.resolve(config.repoRoot, repoPath), config)) return null;
|
|
278
|
+
let identity;
|
|
279
|
+
try { identity = canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config); }
|
|
280
|
+
catch { return null; }
|
|
281
|
+
const recordPath = recordPathForIdentity(identity, config);
|
|
282
|
+
if (!existsSync(recordPath)) return null;
|
|
283
|
+
try { return validateBinding(parseOwnership(readFileSync(recordPath, 'utf8'), recordPath), identity, config); }
|
|
284
|
+
catch { return { corrupt: true, recordPath, raw: null, reason: 'unreadable ownership record' }; }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function prepareOwnershipMigration(oldRepoPath, newPath, config, { sessionId = authoritativeSessionId(), now = new Date().toISOString() } = {}) {
|
|
288
|
+
const ownership = readPlanOwnership(oldRepoPath, config);
|
|
289
|
+
if (!ownership) return null;
|
|
290
|
+
if (ownership.corrupt) throw new Error(`Ownership record is corrupt for ${oldRepoPath}: ${ownership.reason}; repair or release it before rename.`);
|
|
291
|
+
if (ownership.state === 'owned' && ownership.sessionId !== sessionId && !abandonedByDeadSession(ownership)) {
|
|
292
|
+
// `rename` has no --force of its own: a rename carries the claim across to
|
|
293
|
+
// the new path rather than ending it, so the takeover has to happen first.
|
|
294
|
+
throw planBusyError(oldRepoPath, ownership,
|
|
295
|
+
`release it first with \`runlist set <status> ${oldRepoPath} --force\`, then rename.`);
|
|
296
|
+
}
|
|
297
|
+
const identity = plannedPlanIdentity(newPath, config);
|
|
298
|
+
const recordPath = recordPathForIdentity(identity, config);
|
|
299
|
+
const content = recordContent({
|
|
300
|
+
identity,
|
|
301
|
+
sessionId: ownership.sessionId,
|
|
302
|
+
// A rename moves the claim, it does not re-take it: the same session still
|
|
303
|
+
// owns the plan, so its owning process carries across untouched.
|
|
304
|
+
sessionOwner: ownership.sessionOwner,
|
|
305
|
+
state: ownership.state,
|
|
306
|
+
now,
|
|
307
|
+
claimedAt: ownership.claimedAt,
|
|
308
|
+
operation: ownership.operation,
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
oldRecordPath: ownership.recordPath,
|
|
312
|
+
oldContent: ownership.raw,
|
|
313
|
+
newRecordPath: recordPath,
|
|
314
|
+
newContent: content,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function listOwnedPlans(config, sessionId = authoritativeSessionId()) {
|
|
319
|
+
const root = ownershipRoot(config);
|
|
320
|
+
const found = [];
|
|
321
|
+
const diagnostics = [];
|
|
322
|
+
if (!existsSync(root)) return Object.assign(found, { diagnostics });
|
|
323
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
324
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
325
|
+
const recordPath = path.join(root, entry.name);
|
|
326
|
+
let record;
|
|
327
|
+
try { record = parseOwnership(readFileSync(recordPath, 'utf8'), recordPath); }
|
|
328
|
+
catch { record = { corrupt: true, recordPath, reason: 'unreadable ownership record' }; }
|
|
329
|
+
if (record.corrupt) { diagnostics.push(`${entry.name}: ${record.reason}`); continue; }
|
|
330
|
+
if (record.state !== 'owned' || record.sessionId !== sessionId) continue;
|
|
331
|
+
let identity;
|
|
332
|
+
try { identity = canonicalPlanIdentity(path.resolve(config.repoRoot, record.plan), config); }
|
|
333
|
+
catch { diagnostics.push(`${record.plan}: owned record points to a missing or unmanaged plan`); continue; }
|
|
334
|
+
record = validateBinding(record, identity, config);
|
|
335
|
+
if (record.corrupt) { diagnostics.push(`${record.plan}: ${record.reason}`); continue; }
|
|
336
|
+
let planFm;
|
|
337
|
+
try { planFm = parseSimpleFrontmatter(extractFrontmatter(readFileSync(identity.managedPath, 'utf8')).frontmatter); }
|
|
338
|
+
catch { diagnostics.push(`${record.plan}: owned plan cannot be read`); continue; }
|
|
339
|
+
if (asString(planFm.type) !== 'plan' || asString(planFm.status) !== 'in-session') {
|
|
340
|
+
diagnostics.push(`${record.plan}: owned record is stale because the plan is not in-session`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
found.push(record);
|
|
344
|
+
}
|
|
345
|
+
found.sort((a, b) => a.plan.localeCompare(b.plan));
|
|
346
|
+
return Object.assign(found, { diagnostics });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// `sessionOwner` is deliberately additive rather than a schema bump: parseOwnership
|
|
350
|
+
// accepts exactly OWNERSHIP_SCHEMA, so raising it would turn every record already
|
|
351
|
+
// on disk corrupt — which is a worse wedge than the one this fixes. An old record
|
|
352
|
+
// simply has no sessionOwner and stays 'unverifiable' for its whole life; an older
|
|
353
|
+
// runlist reading a new record ignores a field it does not validate.
|
|
354
|
+
function recordContent({ identity, sessionId, state, now, claimedAt, operation, sessionOwner }) {
|
|
355
|
+
return JSON.stringify({
|
|
356
|
+
schema: OWNERSHIP_SCHEMA,
|
|
357
|
+
state,
|
|
358
|
+
plan: identity.repoPath,
|
|
359
|
+
canonicalPath: identity.canonicalPath,
|
|
360
|
+
identityKey: identity.key,
|
|
361
|
+
sessionId,
|
|
362
|
+
sessionOwner: sessionOwner ?? null,
|
|
363
|
+
claimedAt: claimedAt ?? now,
|
|
364
|
+
updatedAt: now,
|
|
365
|
+
operation: operation ?? null,
|
|
366
|
+
}, null, 2) + '\n';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function preparePlanClaim({ filePath, sourceContent, renderedContent, ownership, sessionId, now, config, operationId = randomUUID() }) {
|
|
370
|
+
const identity = canonicalPlanIdentity(filePath, config);
|
|
371
|
+
const recordPath = recordPathForIdentity(identity, config);
|
|
372
|
+
const operation = {
|
|
373
|
+
id: operationId,
|
|
374
|
+
kind: 'claim',
|
|
375
|
+
oldStatus: (() => {
|
|
376
|
+
const match = sourceContent.match(/^status:\s*(.+)$/m);
|
|
377
|
+
return match?.[1]?.trim() ?? null;
|
|
378
|
+
})(),
|
|
379
|
+
index: config.indexPath ? 'pending' : 'skipped',
|
|
380
|
+
hook: 'pending',
|
|
381
|
+
};
|
|
382
|
+
const content = recordContent({ identity, sessionId, state: 'owned', now,
|
|
383
|
+
sessionOwner: sessionProcessOwner(), operation,
|
|
384
|
+
claimedAt: ownership && !ownership.corrupt ? ownership.claimedAt : null });
|
|
385
|
+
const updates = [];
|
|
386
|
+
const guards = [];
|
|
387
|
+
if (renderedContent !== null && renderedContent !== sourceContent) {
|
|
388
|
+
updates.push({ path: filePath, expectedContent: sourceContent, content: renderedContent });
|
|
389
|
+
} else {
|
|
390
|
+
// An `adopt` claim writes only the ownership record — the plan is already
|
|
391
|
+
// `in-session`, so there is nothing to render. The DECISION still rests on
|
|
392
|
+
// the plan's status, which was read before this mutation was prepared, so
|
|
393
|
+
// the plan file joins as a read-only guard. Without it, a concurrent
|
|
394
|
+
// `set active` could win the status write while this claim takes ownership,
|
|
395
|
+
// leaving a record that owns a plan the file calls `active`.
|
|
396
|
+
guards.push({ path: filePath, expectedContent: sourceContent });
|
|
397
|
+
}
|
|
398
|
+
const creations = [];
|
|
399
|
+
if (ownership) updates.push({ path: recordPath, expectedContent: ownership.raw, content });
|
|
400
|
+
else creations.push({ path: recordPath, content });
|
|
401
|
+
return { identity, recordPath, updates, creations, guards, operationId };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function commitPlanClaim(args) {
|
|
405
|
+
const prepared = preparePlanClaim(args);
|
|
406
|
+
mkdirSync(path.dirname(prepared.recordPath), { recursive: true });
|
|
407
|
+
mutateFileSet({ updates: prepared.updates, creations: prepared.creations, guards: prepared.guards }, {
|
|
408
|
+
repoRoot: args.config.repoRoot,
|
|
409
|
+
testHooks: args.testHooks,
|
|
410
|
+
});
|
|
411
|
+
return prepared;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function prepareOwnershipRelease(repoPath, config, { sessionId = authoritativeSessionId(), force = false, now = new Date().toISOString() } = {}) {
|
|
415
|
+
const identity = canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config);
|
|
416
|
+
const ownership = readPlanOwnership(repoPath, config);
|
|
417
|
+
if (!ownership) return null;
|
|
418
|
+
if (ownership.corrupt && !force) throw new Error(`Ownership record is corrupt for ${repoPath}: ${ownership.reason}; use an explicit path with --force to recover.`);
|
|
419
|
+
if (!ownership.corrupt && ownership.state === 'owned' && ownership.sessionId !== sessionId
|
|
420
|
+
&& !force && !abandonedByDeadSession(ownership)) {
|
|
421
|
+
throw planBusyError(repoPath, ownership);
|
|
422
|
+
}
|
|
423
|
+
if (!ownership.corrupt && ownership.state === 'released') return null;
|
|
424
|
+
return {
|
|
425
|
+
path: ownership.recordPath,
|
|
426
|
+
expectedContent: ownership.raw,
|
|
427
|
+
content: recordContent({ identity, sessionId, state: 'released', now,
|
|
428
|
+
claimedAt: ownership.corrupt ? null : ownership.claimedAt, operation: null }),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function assertPlanMutationAuthorized(repoPath, config, { sessionId = authoritativeSessionId(), force = false } = {}) {
|
|
433
|
+
const ownership = readPlanOwnership(repoPath, config);
|
|
434
|
+
if (ownership?.corrupt && !force) {
|
|
435
|
+
throw new Error(`Ownership record is corrupt for ${repoPath}: ${ownership.reason}; use an explicit path with --force to recover.`);
|
|
436
|
+
}
|
|
437
|
+
if (ownership?.state === 'owned' && ownership.sessionId !== sessionId
|
|
438
|
+
&& !force && !abandonedByDeadSession(ownership)) {
|
|
439
|
+
throw planBusyError(repoPath, ownership);
|
|
440
|
+
}
|
|
441
|
+
return ownership;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Every owned claim in the repo, whoever holds it — the survey `listOwnedPlans`
|
|
445
|
+
// deliberately cannot do, since that one answers "what does THIS session own?".
|
|
446
|
+
// Unlike that function this keeps records whose plan has drifted or vanished:
|
|
447
|
+
// a claim pinning a plan nobody can read is exactly the kind that wedges a repo
|
|
448
|
+
// and so is exactly the kind worth showing.
|
|
449
|
+
export function surveyOwnershipClaims(config, now = Date.now()) {
|
|
450
|
+
const root = ownershipRoot(config);
|
|
451
|
+
if (!existsSync(root)) return [];
|
|
452
|
+
const claims = [];
|
|
453
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
454
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
455
|
+
const recordPath = path.join(root, entry.name);
|
|
456
|
+
let record;
|
|
457
|
+
try { record = parseOwnership(readFileSync(recordPath, 'utf8'), recordPath); }
|
|
458
|
+
catch { record = { corrupt: true, recordPath, reason: 'unreadable ownership record' }; }
|
|
459
|
+
if (record.corrupt) {
|
|
460
|
+
claims.push({ recordPath, plan: null, corrupt: true, reason: record.reason, liveness: 'unverifiable', ageMs: null });
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (record.state !== 'owned') continue;
|
|
464
|
+
const since = record.updatedAt ?? record.claimedAt ?? null;
|
|
465
|
+
const parsed = since ? Date.parse(since) : NaN;
|
|
466
|
+
claims.push({
|
|
467
|
+
recordPath,
|
|
468
|
+
plan: record.plan,
|
|
469
|
+
sessionId: record.sessionId,
|
|
470
|
+
corrupt: false,
|
|
471
|
+
since,
|
|
472
|
+
ageMs: Number.isFinite(parsed) ? Math.max(0, now - parsed) : null,
|
|
473
|
+
liveness: ownershipLiveness(record),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
claims.sort((a, b) => (b.ageMs ?? 0) - (a.ageMs ?? 0));
|
|
477
|
+
return claims;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// A claim whose plan file no longer exists — deleted, or renamed by something
|
|
481
|
+
// other than `runlist rename`, which would have carried the record across. The
|
|
482
|
+
// survey deliberately keeps these (a claim pinning a plan nobody can read is
|
|
483
|
+
// exactly the kind worth showing), but the release path could not act on one:
|
|
484
|
+
// it routes through `runSet`, which needs a file to write a status into, so the
|
|
485
|
+
// record survived every `--apply` and sat in the report forever. Worse, the key
|
|
486
|
+
// is a hash of the path, so a plan later created at that same path would read
|
|
487
|
+
// as owned by a session years gone — born wedged.
|
|
488
|
+
//
|
|
489
|
+
// So this releases from the record itself. No `canonicalPlanIdentity` (it
|
|
490
|
+
// realpaths, which is the thing that cannot work here); the record already
|
|
491
|
+
// carries the identity it was written with. Existence is re-checked under the
|
|
492
|
+
// lock, because "the plan is gone" is the entire justification for bypassing
|
|
493
|
+
// the status write, and a `runlist new` racing us would invalidate it.
|
|
494
|
+
export function releaseVanishedPlanClaim(claim, config, { now = new Date().toISOString() } = {}) {
|
|
495
|
+
const recordPath = claim.recordPath;
|
|
496
|
+
return withPathLocks([recordPath], { repoRoot: config.repoRoot }, () => {
|
|
497
|
+
const snapshot = snapshotFile(recordPath);
|
|
498
|
+
const ownership = parseOwnership(snapshot.content, recordPath);
|
|
499
|
+
if (ownership.corrupt || ownership.state !== 'owned') {
|
|
500
|
+
throw new Error(`Claim changed under us for ${claim.plan}; re-run to see the current state.`);
|
|
501
|
+
}
|
|
502
|
+
if (existsSync(path.resolve(config.repoRoot, ownership.plan))) {
|
|
503
|
+
throw new Error(`${ownership.plan} exists after all — release it with \`runlist set active\` instead.`);
|
|
504
|
+
}
|
|
505
|
+
replaceSnapshot(snapshot, JSON.stringify({
|
|
506
|
+
schema: OWNERSHIP_SCHEMA,
|
|
507
|
+
state: 'released',
|
|
508
|
+
plan: ownership.plan,
|
|
509
|
+
canonicalPath: ownership.canonicalPath,
|
|
510
|
+
identityKey: ownership.identityKey,
|
|
511
|
+
sessionId: ownership.sessionId,
|
|
512
|
+
sessionOwner: ownership.sessionOwner ?? null,
|
|
513
|
+
claimedAt: ownership.claimedAt ?? now,
|
|
514
|
+
updatedAt: now,
|
|
515
|
+
operation: null,
|
|
516
|
+
}, null, 2) + '\n', { repoRoot: config.repoRoot, locked: true });
|
|
517
|
+
return ownership.plan;
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function updateOwnershipOperation(repoPath, config, expected, mutate) {
|
|
522
|
+
const ownership = readPlanOwnership(repoPath, config);
|
|
523
|
+
if (!ownership || ownership.corrupt || ownership.state !== 'owned' || !ownership.operation) {
|
|
524
|
+
throw new Error(`Claim completion is stale for ${repoPath}; ownership is no longer active.`);
|
|
525
|
+
}
|
|
526
|
+
if (ownership.sessionId !== expected.sessionId
|
|
527
|
+
|| ownership.operation.id !== expected.operationId
|
|
528
|
+
|| ownership.identityKey !== expected.identityKey) {
|
|
529
|
+
throw new Error(`Claim completion is stale for ${repoPath}; ownership or operation changed.`);
|
|
530
|
+
}
|
|
531
|
+
const next = structuredClone(ownership);
|
|
532
|
+
delete next.recordPath; delete next.raw; delete next.corrupt; delete next.reason;
|
|
533
|
+
mutate(next.operation);
|
|
534
|
+
next.updatedAt = new Date().toISOString();
|
|
535
|
+
mutateFileSet({ updates: [{ path: ownership.recordPath, expectedContent: ownership.raw, content: JSON.stringify(next, null, 2) + '\n' }] }, { repoRoot: config.repoRoot });
|
|
536
|
+
return next;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function validatedClaimOperation(repoPath, config, expected = null) {
|
|
540
|
+
const ownership = readPlanOwnership(repoPath, config);
|
|
541
|
+
if (!ownership || ownership.corrupt || ownership.state !== 'owned' || !ownership.operation) return null;
|
|
542
|
+
const binding = {
|
|
543
|
+
sessionId: ownership.sessionId,
|
|
544
|
+
operationId: ownership.operation.id,
|
|
545
|
+
identityKey: ownership.identityKey,
|
|
546
|
+
};
|
|
547
|
+
if (expected && (binding.sessionId !== expected.sessionId
|
|
548
|
+
|| binding.operationId !== expected.operationId
|
|
549
|
+
|| binding.identityKey !== expected.identityKey)) return null;
|
|
550
|
+
return { ownership, binding, operation: ownership.operation };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function lockedOperation(repoPath, config, expected, callback) {
|
|
554
|
+
const identity = canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config);
|
|
555
|
+
const recordPath = recordPathForIdentity(identity, config);
|
|
556
|
+
return withPathLocks([recordPath], { repoRoot: config.repoRoot }, () => {
|
|
557
|
+
const snapshot = snapshotFile(recordPath);
|
|
558
|
+
const ownership = validateBinding(parseOwnership(snapshot.content, recordPath), identity, config);
|
|
559
|
+
if (ownership.corrupt || ownership.state !== 'owned' || !ownership.operation
|
|
560
|
+
|| ownership.sessionId !== expected.sessionId
|
|
561
|
+
|| ownership.operation.id !== expected.operationId
|
|
562
|
+
|| ownership.identityKey !== expected.identityKey) {
|
|
563
|
+
throw new Error(`Claim completion is stale for ${repoPath}; ownership or operation changed.`);
|
|
564
|
+
}
|
|
565
|
+
return callback({ ownership, snapshot });
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function writeLockedOperation(ownership, snapshot, config, mutate) {
|
|
570
|
+
const next = structuredClone(ownership);
|
|
571
|
+
delete next.recordPath; delete next.raw; delete next.corrupt; delete next.reason;
|
|
572
|
+
mutate(next.operation);
|
|
573
|
+
next.updatedAt = new Date().toISOString();
|
|
574
|
+
replaceSnapshot(snapshot, JSON.stringify(next, null, 2) + '\n', { repoRoot: config.repoRoot, locked: true });
|
|
575
|
+
return next;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function beginClaimHookDelivery(repoPath, config, expected, options = {}) {
|
|
579
|
+
const now = options.now ?? new Date();
|
|
580
|
+
const leaseMs = options.leaseMs ?? HOOK_DELIVERY_LEASE_MS;
|
|
581
|
+
const ownerLiveness = options.ownerLiveness ?? processOwnerLiveness;
|
|
582
|
+
return lockedOperation(repoPath, config, expected, ({ ownership, snapshot }) => {
|
|
583
|
+
const operation = ownership.operation;
|
|
584
|
+
if (['done', 'skipped'].includes(operation.hook)) return null;
|
|
585
|
+
if (operation.hook === 'delivering') {
|
|
586
|
+
const age = now.getTime() - Date.parse(operation.hookDeliveryStartedAt ?? '');
|
|
587
|
+
const liveness = ownerLiveness(operation.hookDeliveryOwner);
|
|
588
|
+
if (!Number.isFinite(age) || age < leaseMs || liveness !== 'dead') {
|
|
589
|
+
return { busy: true, operationId: operation.id, liveness };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const token = randomUUID();
|
|
593
|
+
const next = writeLockedOperation(ownership, snapshot, config, op => {
|
|
594
|
+
op.hook = 'delivering';
|
|
595
|
+
op.hookDeliveryToken = token;
|
|
596
|
+
op.hookDeliveryStartedAt = now.toISOString();
|
|
597
|
+
op.hookDeliveryOwner = currentProcessOwner();
|
|
598
|
+
});
|
|
599
|
+
return { busy: false, token, operation: next.operation };
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function finishClaimHookDelivery(repoPath, config, expected, token) {
|
|
604
|
+
return lockedOperation(repoPath, config, expected, ({ ownership, snapshot }) => {
|
|
605
|
+
if (ownership.operation.hook !== 'delivering' || ownership.operation.hookDeliveryToken !== token) {
|
|
606
|
+
throw new Error(`Claim hook delivery token is stale for ${repoPath}.`);
|
|
607
|
+
}
|
|
608
|
+
return writeLockedOperation(ownership, snapshot, config, op => {
|
|
609
|
+
op.hook = 'done';
|
|
610
|
+
delete op.hookDeliveryToken;
|
|
611
|
+
delete op.hookDeliveryStartedAt;
|
|
612
|
+
delete op.hookDeliveryOwner;
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export function abandonClaimHookDelivery(repoPath, config, expected, token) {
|
|
618
|
+
try {
|
|
619
|
+
return lockedOperation(repoPath, config, expected, ({ ownership, snapshot }) => {
|
|
620
|
+
if (ownership.operation.hook !== 'delivering' || ownership.operation.hookDeliveryToken !== token) return ownership;
|
|
621
|
+
return writeLockedOperation(ownership, snapshot, config, op => {
|
|
622
|
+
op.hook = 'pending';
|
|
623
|
+
delete op.hookDeliveryToken;
|
|
624
|
+
delete op.hookDeliveryStartedAt;
|
|
625
|
+
delete op.hookDeliveryOwner;
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
} catch { return null; }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export function skipClaimHookDelivery(repoPath, config, expected, token) {
|
|
632
|
+
return lockedOperation(repoPath, config, expected, ({ ownership, snapshot }) => {
|
|
633
|
+
if (ownership.operation.hook !== 'delivering' || ownership.operation.hookDeliveryToken !== token) {
|
|
634
|
+
throw new Error(`Claim hook delivery token is stale for ${repoPath}.`);
|
|
635
|
+
}
|
|
636
|
+
return writeLockedOperation(ownership, snapshot, config, op => {
|
|
637
|
+
op.hook = 'skipped';
|
|
638
|
+
delete op.hookDeliveryToken;
|
|
639
|
+
delete op.hookDeliveryStartedAt;
|
|
640
|
+
delete op.hookDeliveryOwner;
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export function assertHookDeliveryTakeoverSafe(ownership, options = {}) {
|
|
646
|
+
if (ownership?.operation?.hook !== 'delivering') return;
|
|
647
|
+
const now = options.now ?? new Date();
|
|
648
|
+
const leaseMs = options.leaseMs ?? HOOK_DELIVERY_LEASE_MS;
|
|
649
|
+
const age = now.getTime() - Date.parse(ownership.operation.hookDeliveryStartedAt ?? '');
|
|
650
|
+
const liveness = (options.ownerLiveness ?? processOwnerLiveness)(ownership.operation.hookDeliveryOwner);
|
|
651
|
+
if (!Number.isFinite(age) || age < leaseMs || liveness !== 'dead') {
|
|
652
|
+
throw new Error(`Plan hook delivery is active (${liveness}); force takeover is refused until its owner is demonstrably dead and the delivery lease expires.`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function pickupFactsForDoc(doc, config, { sessionId = availableSessionId() } = {}) {
|
|
657
|
+
const ownership = doc?.path ? readPlanOwnership(doc.path, config) : null;
|
|
658
|
+
return classifyPlanPickup({
|
|
659
|
+
type: doc?.type ?? null,
|
|
660
|
+
status: doc?.status ?? null,
|
|
661
|
+
validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
|
|
662
|
+
ownerLiveness: ownershipLiveness(ownership),
|
|
663
|
+
startableStatuses: config.lifecycle.startableStatuses,
|
|
664
|
+
terminalStatuses: config.lifecycle.terminalStatuses,
|
|
665
|
+
archiveStatuses: config.lifecycle.archiveStatuses,
|
|
666
|
+
physicallyArchived: Boolean(doc?.path?.split('/').includes(config.archiveDir)),
|
|
667
|
+
ownership,
|
|
668
|
+
sessionId,
|
|
669
|
+
malformed: !doc || doc.parseError === true,
|
|
670
|
+
});
|
|
671
|
+
}
|