@mcrescenzo/opencode-workflows 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.
Files changed (71) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CODE_OF_CONDUCT.md +134 -0
  3. package/CONTRIBUTING.md +95 -0
  4. package/LICENSE +21 -0
  5. package/README.md +746 -0
  6. package/SECURITY.md +38 -0
  7. package/commands/repo-bughunt.md +94 -0
  8. package/commands/repo-review.md +148 -0
  9. package/commands/workflow-live-gates-release-check.md +143 -0
  10. package/docs/workflow-plugin.md +400 -0
  11. package/opencode-workflows.js +5 -0
  12. package/package.json +86 -0
  13. package/skills/opencode-workflow-authoring/SKILL.md +180 -0
  14. package/skills/repo-review-command-protocol/SKILL.md +56 -0
  15. package/skills/workflow-model-tiering/SKILL.md +57 -0
  16. package/skills/workflow-plan-review/SKILL.md +91 -0
  17. package/workflow-kernel/approval-hashing.js +39 -0
  18. package/workflow-kernel/async-util.js +33 -0
  19. package/workflow-kernel/audited-shell-policy.js +200 -0
  20. package/workflow-kernel/authority-policy.js +670 -0
  21. package/workflow-kernel/budget-accounting.js +142 -0
  22. package/workflow-kernel/capability-adapter.js +753 -0
  23. package/workflow-kernel/child-agent-runner.js +1264 -0
  24. package/workflow-kernel/constants.js +117 -0
  25. package/workflow-kernel/diagnostics.js +152 -0
  26. package/workflow-kernel/drain-runtime.js +421 -0
  27. package/workflow-kernel/errors.js +181 -0
  28. package/workflow-kernel/event-journal.js +487 -0
  29. package/workflow-kernel/extension-registry.js +144 -0
  30. package/workflow-kernel/free-text-redactor.js +91 -0
  31. package/workflow-kernel/gate-shapes.js +82 -0
  32. package/workflow-kernel/git-util.js +45 -0
  33. package/workflow-kernel/index.js +72 -0
  34. package/workflow-kernel/integration-mode.js +155 -0
  35. package/workflow-kernel/lane-effort-policy.js +134 -0
  36. package/workflow-kernel/lifecycle-control.js +608 -0
  37. package/workflow-kernel/live-gate-probes.js +916 -0
  38. package/workflow-kernel/notification-toast-cards.js +393 -0
  39. package/workflow-kernel/notification-toast-policy.js +179 -0
  40. package/workflow-kernel/notification-toast-scope.js +100 -0
  41. package/workflow-kernel/notification-toast.js +287 -0
  42. package/workflow-kernel/path-policy.js +219 -0
  43. package/workflow-kernel/result-readback.js +106 -0
  44. package/workflow-kernel/role-template-loading.js +606 -0
  45. package/workflow-kernel/run-context.js +139 -0
  46. package/workflow-kernel/run-observability.js +43 -0
  47. package/workflow-kernel/run-store-fs.js +231 -0
  48. package/workflow-kernel/run-store-locks.js +180 -0
  49. package/workflow-kernel/run-store-projections.js +421 -0
  50. package/workflow-kernel/run-store-rehydrate.js +68 -0
  51. package/workflow-kernel/run-store-state.js +147 -0
  52. package/workflow-kernel/run-store-status-format.js +1154 -0
  53. package/workflow-kernel/run-store-status.js +107 -0
  54. package/workflow-kernel/sandbox-executor.js +1131 -0
  55. package/workflow-kernel/session-access.js +56 -0
  56. package/workflow-kernel/structured-output.js +143 -0
  57. package/workflow-kernel/test-fix-drain-adapter.js +119 -0
  58. package/workflow-kernel/text-json.js +136 -0
  59. package/workflow-kernel/workflow-plugin.js +3017 -0
  60. package/workflow-kernel/workflow-source.js +444 -0
  61. package/workflow-kernel/worktree-adapter.js +256 -0
  62. package/workflow-kernel/worktree-git.js +147 -0
  63. package/workflows/repo-bughunt.js +362 -0
  64. package/workflows/repo-cleanup.js +383 -0
  65. package/workflows/repo-complexity.js +404 -0
  66. package/workflows/repo-deps.js +457 -0
  67. package/workflows/repo-modernize.js +395 -0
  68. package/workflows/repo-perf.js +394 -0
  69. package/workflows/repo-review.js +831 -0
  70. package/workflows/repo-security-audit.js +466 -0
  71. package/workflows/repo-test-gaps.js +377 -0
@@ -0,0 +1,139 @@
1
+ // RunContext: the mutable per-run state object that threads through the workflow
2
+ // orchestrator (workflow-plugin.js) and every extracted boundary it dispatches to
3
+ // (sandbox-executor.js, child-agent-runner.js, run-store-status.js, lifecycle-control.js,
4
+ // event-journal.js, budget-accounting.js, ...).
5
+ //
6
+ // This module is intentionally type-only: it carries NO runtime logic. Its single job is
7
+ // to document the coupling surface — the ~70-property "run" object that the staged
8
+ // god-object split (opencode-workflows-96b) decomposes around. Extracted modules import
9
+ // the {@link RunContext} typedef so the property contract each boundary reads/writes is
10
+ // explicit rather than implicit, and so the natural module split points (which subset of
11
+ // these properties a concern actually touches) become visible.
12
+ //
13
+ // The canonical construction site is startWorkflow() in workflow-plugin.js. resume seeds a
14
+ // subset from prior on-disk state via rehydrateRunFromPriorState (run-store-status.js).
15
+ // Mutation here is deliberate: the run object IS the orchestrator's shared mutable state;
16
+ // typing it does not freeze it.
17
+
18
+ /**
19
+ * Per-lane abort wiring tracked for in-flight child agents, keyed by callId.
20
+ *
21
+ * @typedef {object} ActiveLaneAbort
22
+ * @property {AbortController} abortController Lane-scoped controller, chained to run.abortController.
23
+ * @property {string|undefined} childID Child session id once session.create returns.
24
+ * @property {string|undefined} directory Working directory (worktree path or tool directory).
25
+ */
26
+
27
+ /**
28
+ * A pending concurrency-slot waiter (see acquireAgentSlot / releaseAgentSlot).
29
+ *
30
+ * @typedef {object} WaitingAgent
31
+ * @property {() => void} resolve Hand off the slot to this waiter.
32
+ * @property {(error: Error) => void} reject Reject the waiter (cancellation / fanout abort).
33
+ * @property {string} callId Lane call id, used by fanout cancellation to target waiters.
34
+ */
35
+
36
+ /**
37
+ * Token accounting bucket (live or replayed).
38
+ *
39
+ * @typedef {object} TokenUsage
40
+ * @property {number} input
41
+ * @property {number} output
42
+ * @property {number} reasoning
43
+ */
44
+
45
+ /**
46
+ * The mutable run context threaded through the orchestrator and its boundaries.
47
+ *
48
+ * Grouped by concern to make the split surface legible. The grouping is documentation
49
+ * only; at runtime this is one flat object.
50
+ *
51
+ * @typedef {object} RunContext
52
+ *
53
+ * --- identity / source / approval envelope ---
54
+ * @property {string} id Run id (resume id or a fresh uuid).
55
+ * @property {string} dir Durable run directory (state.json, journal.jsonl, lanes/, worktrees/).
56
+ * @property {string} sourcePath Resolved workflow source path ("<inline>" for inline sources).
57
+ * @property {string} sourceHash Content hash of the workflow source (approval-bound).
58
+ * @property {object} meta Parsed workflow meta (name, description, phases, ...).
59
+ * @property {object} authority Resolved authority profile (edit/worktreeEdit/integration, gates, isolation).
60
+ * @property {*} runtimeArgs Caller-supplied runtime args (approval-bound).
61
+ * @property {string} argsPreview Redacted preview of runtimeArgs for approval/status text.
62
+ * @property {Map<string, object>} nestedSnapshots Approved nested-workflow source snapshots, by path and hash.
63
+ * @property {boolean} [externalSource] True when the source was loaded via allowExternalScriptPath opt-in.
64
+ *
65
+ * --- lifecycle / status ---
66
+ * @property {string} status running | cancelling | pausing | paused | interrupted | completed | failed | ...
67
+ * @property {string} startedAt ISO start timestamp (preserved across resume).
68
+ * @property {string|undefined} resumedAt ISO timestamp set when a run resumes.
69
+ * @property {string|undefined} finishedAt ISO completion timestamp.
70
+ * @property {string|undefined} currentPhase Most recent phase() name.
71
+ * @property {Error|string|undefined} error Terminal error, if any.
72
+ * @property {{enabled: boolean, source?: string}|undefined} debugCapture Opt-in prompt/schema/transcript capture state.
73
+ * @property {string|undefined} firstResultAt First successful lane completion timestamp.
74
+ * @property {number|undefined} timeToFirstResultMs Duration from run start to first successful lane.
75
+ * @property {{startedAt?: string, completedAt?: string, durationMs?: number, diffPlanHash?: string}|undefined} approvalWait Diff-approval wait metric.
76
+ * @property {string[]} recentLogs Last narrator log() lines, bounded for toast/status display.
77
+ * @property {object|undefined} lifecycleRequests Last-read durable cancel/pause request envelope.
78
+ * @property {boolean} pauseRequested Set when a durable pause request is observed.
79
+ * @property {boolean} background True for fire-and-forget background runs.
80
+ * @property {boolean} ignoreToolAbort True when the run must ignore the tool-call abort signal (background).
81
+ * @property {Promise<*>} [done] Background run completion promise.
82
+ *
83
+ * --- budget / concurrency / model plan ---
84
+ * @property {number} agentsStarted Child agents launched (carried across resume).
85
+ * @property {number} maxAgents Hard cap on agents started.
86
+ * @property {number} concurrency Max concurrent active agents.
87
+ * @property {number} activeAgents Currently running agents (gated by concurrency).
88
+ * @property {WaitingAgent[]} waitingAgents FIFO queue of agents awaiting a concurrency slot.
89
+ * @property {number} laneTimeoutMs Default per-lane prompt timeout.
90
+ * @property {string} defaultChildModel Fallback child model.
91
+ * @property {{fast?: string, deep?: string}|undefined} modelTiers Lane model tier map.
92
+ * @property {TokenUsage} tokens Live token usage accumulated this session.
93
+ * @property {TokenUsage} replayedTokens Token usage carried forward from prior segments at resume.
94
+ * @property {number} cost Live cost accumulated this session.
95
+ * @property {number} replayedCost Cost carried forward from prior segments at resume.
96
+ * @property {{maxCost?: number, maxTokens?: number}|undefined} budgetCeilings Budget stop ceilings.
97
+ * @property {{hits: number, misses: number, invalidated: number}} cacheStats Resume cache-hit accounting.
98
+ * @property {Object<string, number>} laneOutcomes Per-outcome lane counters.
99
+ * @property {number} droppedLaneCount Fanout lanes dropped (non-failFast failures).
100
+ * @property {number} guestDeadlineMs Interrupt deadline budget for synchronous guest bursts.
101
+ * @property {number} hostCalls Host-op call counter (per-run scaled host-call guard).
102
+ *
103
+ * --- capability / adapter surface ---
104
+ * @property {object} capabilities Resolved client capabilities (childSession, structuredOutput, worktree, ...).
105
+ * @property {object} diagnostics Capability/probe diagnostics (incl. drainLiveGates).
106
+ * @property {object} adapter Capability adapter (getStructured, createWorktree, removeWorktree, ...).
107
+ * @property {object} [worktreeAdapter] Native integration worktree adapter (lazily created).
108
+ *
109
+ * --- edit / integration / worktrees ---
110
+ * @property {object[]} editWorktrees Throwaway edit-lane worktree records.
111
+ * @property {object|undefined} editPlan Accumulated diff plan (patches, baseCommit) for edit authority.
112
+ * @property {object|undefined} integrationPlan Integration plan (lanes, baseCommit, integrationResult).
113
+ * @property {object[]} integrationWorktrees Integration-lane worktree records.
114
+ * @property {object} [worktreeCleanup] Worktree cleanup summary ledger.
115
+ *
116
+ * --- lanes / journal / children ---
117
+ * @property {Map<string, object>} laneRecords In-memory lane outcome records, by callId.
118
+ * @property {Map<string, string>} children Active child session ids -> directory.
119
+ * @property {Map<string, ActiveLaneAbort>} activeLaneAbortControllers In-flight lane abort wiring.
120
+ * @property {Set<string>} cancelledFanoutScopes Fanout scopes cancelled by failFast.
121
+ * @property {Map<string, object>} resumeJournal Prior-segment journal entries consulted on resume.
122
+ * @property {Map<string, Map<string, object[]>>} resumeSignatureIndex Prior successful journal entries by lane signature and fallback scope.
123
+ * @property {Set<string>} resumeSignatureClaims Prior callIds already consumed by exact or signature resume replay.
124
+ * @property {number} eventCount Append-only events.jsonl line count (MAX guard).
125
+ * @property {number} journalRecords Append-only journal.jsonl line count (MAX guard).
126
+ * @property {(record: object, run: RunContext) => (Promise<void>|void)|undefined} eventSink Optional best-effort observer invoked after events append.
127
+ * @property {number} nestingDepth Current nested-workflow recursion depth (max 1).
128
+ *
129
+ * --- control / notification ---
130
+ * @property {AbortController} abortController Run-wide abort controller; lane controllers chain to it.
131
+ * @property {object|undefined} notificationTarget Background completion-notification target.
132
+ * @property {object} [notification] Completion-notification record/state.
133
+ * @property {() => Promise<void>|void} [releaseRunLock] Release the durable run lock.
134
+ */
135
+
136
+ // No runtime exports: this module exists purely to host the shared typedef. A named export
137
+ // keeps it a valid ES module and gives importers a stable specifier to attach the
138
+ // `@typedef {import("./run-context.js").RunContext}` reference to.
139
+ export const RUN_CONTEXT_TYPEDEF = "RunContext";
@@ -0,0 +1,43 @@
1
+ const RECENT_LOG_LIMIT = 3;
2
+
3
+ function boundedString(value) {
4
+ return String(value ?? "");
5
+ }
6
+
7
+ function normalizeRecentLogs(logs, options = {}) {
8
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : RECENT_LOG_LIMIT;
9
+ if (!Array.isArray(logs)) return [];
10
+ return logs.map(boundedString).filter((line) => line.length > 0).slice(-limit);
11
+ }
12
+
13
+ function recordRecentLog(run, message, options = {}) {
14
+ if (!run || typeof run !== "object") return [];
15
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : RECENT_LOG_LIMIT;
16
+ const logs = normalizeRecentLogs(run.recentLogs, { limit });
17
+ const text = boundedString(message);
18
+ if (text) logs.push(text);
19
+ run.recentLogs = logs.slice(-limit);
20
+ return run.recentLogs;
21
+ }
22
+
23
+ function notifyRunEventSink(run, record) {
24
+ const sink = run?.eventSink;
25
+ if (typeof sink !== "function") return;
26
+ try {
27
+ const result = sink(record, run);
28
+ if (result && typeof result.then === "function") {
29
+ result.catch(() => {
30
+ // Event sinks are observers; rejected delivery must not affect journaling.
31
+ });
32
+ }
33
+ } catch {
34
+ // Event sinks are observers; thrown delivery must not affect journaling.
35
+ }
36
+ }
37
+
38
+ export {
39
+ RECENT_LOG_LIMIT,
40
+ normalizeRecentLogs,
41
+ recordRecentLog,
42
+ notifyRunEventSink,
43
+ };
@@ -0,0 +1,231 @@
1
+ // Shared run-store primitives: FS/JSON atomic helpers, run-id/path containment guards,
2
+ // run-root resolution, PID liveness, and the in-memory `runs` registry. These are the
3
+ // concern-agnostic building blocks that every run-store module (durable state, locks,
4
+ // projections, rehydration, status formatting) reads/writes; they were extracted from the
5
+ // former 957-line run-store-status.js (opencode-workflows-nbp) so each concern module can
6
+ // depend on this base rather than on each other.
7
+
8
+ import crypto from "node:crypto";
9
+ import fs from "node:fs/promises";
10
+ import path from "node:path";
11
+ import { GLOBAL_WORKFLOW_DIR, RUN_ID_RE } from "./constants.js";
12
+ import { hash } from "./text-json.js";
13
+ import { projectWorkflowDir } from "./workflow-source.js";
14
+ import { pathContains as isPathInside } from "./path-policy.js";
15
+
16
+ const PRIVATE_DIR_MODE = 0o700;
17
+ const PRIVATE_FILE_MODE = 0o600;
18
+ const LOCK_LIVENESS_FALLBACK_TTL_MS = 24 * 60 * 60 * 1000;
19
+
20
+ async function chmodBestEffort(filePath, mode) {
21
+ try {
22
+ await fs.chmod(filePath, mode);
23
+ } catch {
24
+ // chmod can be unsupported on some filesystems. Creation still requested the
25
+ // restrictive mode; keep workflow execution moving if the chmod backstop is unavailable.
26
+ }
27
+ }
28
+
29
+ async function ensurePrivateDir(dir) {
30
+ await fs.mkdir(dir, { recursive: true, mode: PRIVATE_DIR_MODE });
31
+ await chmodBestEffort(dir, PRIVATE_DIR_MODE);
32
+ }
33
+
34
+ function privateFileOptions(options = "utf8") {
35
+ if (typeof options === "string") return { encoding: options, mode: PRIVATE_FILE_MODE };
36
+ return { mode: PRIVATE_FILE_MODE, ...options };
37
+ }
38
+
39
+ async function writeFilePrivate(filePath, data, options = "utf8") {
40
+ await ensurePrivateDir(path.dirname(filePath));
41
+ await fs.writeFile(filePath, data, privateFileOptions(options));
42
+ await chmodBestEffort(filePath, PRIVATE_FILE_MODE);
43
+ }
44
+
45
+ async function appendFilePrivate(filePath, data, options = "utf8") {
46
+ await ensurePrivateDir(path.dirname(filePath));
47
+ await fs.appendFile(filePath, data, privateFileOptions(options));
48
+ await chmodBestEffort(filePath, PRIVATE_FILE_MODE);
49
+ }
50
+
51
+ // Generic FS-JSON helper (read with fallback) — colocated with writeJsonAtomic/pathExists.
52
+ async function readJsonFile(filePath, fallback) {
53
+ try {
54
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
55
+ } catch (error) {
56
+ if (error.code === "ENOENT") return fallback;
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ async function writeJsonAtomic(filePath, value) {
62
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
63
+ try {
64
+ await writeFilePrivate(tmp, JSON.stringify(value, null, 2), "utf8");
65
+ await fs.rename(tmp, filePath);
66
+ await chmodBestEffort(filePath, PRIVATE_FILE_MODE);
67
+ } catch (error) {
68
+ // A write/rename failure (EACCES/ENOSPC) can otherwise orphan a tmp file on disk.
69
+ await fs.rm(tmp, { force: true }).catch(() => {});
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ async function pathExists(filePath) {
75
+ try {
76
+ await fs.access(filePath);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ const runs = new Map();
84
+
85
+ let selfStartTime;
86
+
87
+ function assertSafeRunId(runId, label = "runId") {
88
+ if (typeof runId !== "string" || !RUN_ID_RE.test(runId) || runId === "." || runId === "..") {
89
+ throw new Error(`${label} must be a simple run id without path separators`);
90
+ }
91
+ return runId;
92
+ }
93
+
94
+ function runDirForRoot(root, runId) {
95
+ assertSafeRunId(runId);
96
+ const resolvedRoot = path.resolve(root);
97
+ const resolved = path.resolve(resolvedRoot, runId);
98
+ if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error(`runId escapes workflow run root: ${runId}`);
99
+ return resolved;
100
+ }
101
+
102
+ async function processStartTime(pid) {
103
+ // Linux-only best effort: field 22 of /proc/<pid>/stat is the process start time. The
104
+ // comm (field 2) can contain spaces/parens, so parse after the final ')'.
105
+ try {
106
+ const stat = await fs.readFile(`/proc/${pid}/stat`, "utf8");
107
+ const fields = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
108
+ const starttime = Number(fields[19]);
109
+ return Number.isFinite(starttime) ? starttime : undefined;
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ async function selfProcessStartTime() {
116
+ if (selfStartTime === undefined) selfStartTime = (await processStartTime(process.pid)) ?? null;
117
+ return selfStartTime === null ? undefined : selfStartTime;
118
+ }
119
+
120
+ async function processAppearsAlive(processInfo, {
121
+ readStartTime = processStartTime,
122
+ now = Date.now(),
123
+ lockTtlMs = LOCK_LIVENESS_FALLBACK_TTL_MS,
124
+ } = {}) {
125
+ const owner = processInfo && typeof processInfo === "object" && processInfo.process ? processInfo.process : processInfo;
126
+ const pid = typeof owner === "number" ? owner : owner?.pid;
127
+ if (!Number.isInteger(pid) || pid <= 0) return false;
128
+ let alive;
129
+ try {
130
+ process.kill(pid, 0);
131
+ alive = true;
132
+ } catch (error) {
133
+ alive = error.code === "EPERM";
134
+ }
135
+ if (!alive) return false;
136
+ // Guard against PID reuse: if the owner's process start time was recorded, compare it to
137
+ // the live process's start time. When NO start time was recorded (non-Linux owner, legacy
138
+ // state) we have nothing to compare against, so fall back to the bare liveness check.
139
+ const recordedStart = typeof owner === "object" ? owner?.startTime : undefined;
140
+ if (Number.isFinite(recordedStart)) {
141
+ // A recorded start exists, so we expect to be able to confirm it. Distrust the PID unless
142
+ // the live start is readable AND matches: a non-finite liveStart (/proc unreadable,
143
+ // hidepid/EPERM, TOCTOU) is treated as a mismatch rather than blind trust, so a reused PID
144
+ // is not pinned as active and reconcile/cleanup is not blocked.
145
+ const liveStart = await readStartTime(pid);
146
+ if (!Number.isFinite(liveStart) || liveStart !== recordedStart) return false;
147
+ } else if (processInfo && typeof processInfo === "object" && typeof processInfo.acquiredAt === "string") {
148
+ const acquiredMs = Date.parse(processInfo.acquiredAt);
149
+ if (Number.isFinite(acquiredMs) && Number.isFinite(lockTtlMs) && lockTtlMs > 0 && now - acquiredMs >= lockTtlMs) {
150
+ return false;
151
+ }
152
+ }
153
+ return true;
154
+ }
155
+
156
+ async function currentProcessInfo() {
157
+ return { pid: process.pid, startTime: await selfProcessStartTime() };
158
+ }
159
+
160
+ function runRoot(context) {
161
+ return path.join(projectWorkflowDir(context), "runs");
162
+ }
163
+
164
+ function globalRunRoot(context) {
165
+ const key = hash(path.resolve(context.worktree || context.directory || ".")).slice(0, 16);
166
+ return path.join(GLOBAL_WORKFLOW_DIR, "runs", key);
167
+ }
168
+
169
+ function runRoots(context) {
170
+ return [...new Set([runRoot(context), globalRunRoot(context)])];
171
+ }
172
+
173
+ async function ensureRunRoot(context) {
174
+ for (const root of runRoots(context)) {
175
+ try {
176
+ await ensurePrivateDir(root);
177
+ await writeFilePrivate(path.join(root, ".gitignore"), "*\n!.gitignore\n", "utf8");
178
+ return root;
179
+ } catch (error) {
180
+ if (error.code !== "EACCES" && error.code !== "EROFS" && error.code !== "EPERM") throw error;
181
+ }
182
+ }
183
+ throw new Error("Could not create a writable workflow run directory");
184
+ }
185
+
186
+ async function assertContainedRealPath(root, candidate, label) {
187
+ const rootReal = await fs.realpath(root);
188
+ const candidateReal = await fs.realpath(candidate);
189
+ if (!isPathInside(rootReal, candidateReal)) throw new Error(`${label} escapes expected root: ${candidate}`);
190
+ return { rootReal, candidateReal };
191
+ }
192
+
193
+ async function assertContainedRunDir(root, dir) {
194
+ await assertContainedRealPath(root, dir, "Workflow run directory");
195
+ const stat = await fs.lstat(dir);
196
+ if (stat.isSymbolicLink()) throw new Error(`Workflow run directory is a symlink: ${dir}`);
197
+ if (!stat.isDirectory()) throw new Error(`Workflow run path is not a directory: ${dir}`);
198
+ }
199
+
200
+ function safeProjectionName(value) {
201
+ const safe = String(value ?? "").replace(/[^a-z0-9_.-]+/gi, "_").replace(/^_+|_+$/g, "").slice(0, 120);
202
+ return safe || hash(String(value ?? "lane")).slice(0, 16);
203
+ }
204
+
205
+ export {
206
+ PRIVATE_DIR_MODE,
207
+ PRIVATE_FILE_MODE,
208
+ LOCK_LIVENESS_FALLBACK_TTL_MS,
209
+ runs,
210
+ selfStartTime,
211
+ readJsonFile,
212
+ writeJsonAtomic,
213
+ ensurePrivateDir,
214
+ writeFilePrivate,
215
+ appendFilePrivate,
216
+ pathExists,
217
+ assertSafeRunId,
218
+ runDirForRoot,
219
+ processStartTime,
220
+ selfProcessStartTime,
221
+ processAppearsAlive,
222
+ currentProcessInfo,
223
+ runRoot,
224
+ globalRunRoot,
225
+ runRoots,
226
+ ensureRunRoot,
227
+ assertContainedRealPath,
228
+ assertContainedRunDir,
229
+ safeProjectionName,
230
+ isPathInside,
231
+ };
@@ -0,0 +1,180 @@
1
+ // Concern (2): lock-file management. Atomic exclusive run/apply/cleanup lock acquisition,
2
+ // lock reading with PID-liveness staleness, stale/corrupt lock clearing during reconcile,
3
+ // and durable cancel/pause lifecycle request envelopes. Extracted from run-store-status.js
4
+ // (opencode-workflows-nbp). Reads/writes the lock + lifecycle-request files under a run dir;
5
+ // the RunContext properties it touches are limited to durable on-disk lock state (not the
6
+ // in-memory run object). See {@link import("./run-context.js").RunContext}.
7
+
8
+ import crypto from "node:crypto";
9
+ import fs from "node:fs/promises";
10
+ import path from "node:path";
11
+ import { DURABLE_STATE_VERSION } from "./constants.js";
12
+ import { extractTextFromError } from "./text-json.js";
13
+ import {
14
+ currentProcessInfo,
15
+ ensurePrivateDir,
16
+ processAppearsAlive,
17
+ readJsonFile,
18
+ writeFilePrivate,
19
+ writeJsonAtomic,
20
+ } from "./run-store-fs.js";
21
+
22
+ const RUN_LOCK_FILE = "run.lock";
23
+
24
+ const APPLY_LOCK_FILE = "apply.lock";
25
+
26
+ const CLEANUP_LOCK_FILE = ".cleanup.lock";
27
+
28
+ const CANCEL_REQUEST_FILE = "cancel-request.json";
29
+
30
+ const PAUSE_REQUEST_FILE = "pause-request.json";
31
+
32
+ const KILL_REQUEST_FILE = "kill-request.json";
33
+
34
+ function lockPathForRun(dir, operation) {
35
+ if (operation === "run") return path.join(dir, RUN_LOCK_FILE);
36
+ if (operation === "apply") return path.join(dir, APPLY_LOCK_FILE);
37
+ throw new Error(`Unknown workflow lock operation: ${operation}`);
38
+ }
39
+
40
+ function cleanupLockPath(root) {
41
+ return path.join(root, CLEANUP_LOCK_FILE);
42
+ }
43
+
44
+ async function readLock(lockPath) {
45
+ // Split reading from parsing. A failed READ (EACCES/EMFILE/ENFILE/EIO/EISDIR —
46
+ // plausible under this codebase's many concurrent lanes) tells us nothing about
47
+ // whether the lock's owner is alive, so it is classified `unreadable`, NOT
48
+ // `corrupt`. Only a genuine JSON.parse failure means the on-disk bytes are
49
+ // malformed (a partial write from a crash) — the one case reconcile may clear.
50
+ // If read errors were treated as corrupt, a transient FS hiccup at the wrong
51
+ // moment would let clearStaleRunLocks delete an actively-held lock.
52
+ let content;
53
+ try {
54
+ content = await fs.readFile(lockPath, "utf8");
55
+ } catch (error) {
56
+ if (error.code === "ENOENT") return null;
57
+ return { path: lockPath, active: false, stale: false, unreadable: true, error: extractTextFromError(error) };
58
+ }
59
+ let metadata;
60
+ try {
61
+ metadata = JSON.parse(content);
62
+ } catch (error) {
63
+ return { path: lockPath, active: false, stale: false, corrupt: true, error: extractTextFromError(error) };
64
+ }
65
+ const active = await processAppearsAlive(metadata);
66
+ return { ...metadata, path: lockPath, active, stale: !active };
67
+ }
68
+
69
+ async function acquireWorkflowLock(lockPath, metadata) {
70
+ await ensurePrivateDir(path.dirname(lockPath));
71
+ const record = {
72
+ stateVersion: DURABLE_STATE_VERSION,
73
+ acquiredAt: new Date().toISOString(),
74
+ ...metadata,
75
+ process: await currentProcessInfo(),
76
+ };
77
+ // Lock creation must be atomic so a crash mid-write cannot leave a partial
78
+ // (corrupt) lock file that wedges the run. Write the full record to a temp
79
+ // file, then hard-link it into place: fs.link fails with EEXIST if the lock
80
+ // already exists, giving us atomic exclusive create with fully-written bytes.
81
+ const tmp = `${lockPath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
82
+ try {
83
+ await writeFilePrivate(tmp, JSON.stringify(record, null, 2), "utf8");
84
+ await fs.link(tmp, lockPath);
85
+ } catch (error) {
86
+ if (error.code === "EEXIST") {
87
+ const existing = await readLock(lockPath);
88
+ const state = existing?.active ? "active" : existing?.stale ? "stale" : existing?.corrupt ? "corrupt" : "unreadable";
89
+ const recovery = (existing?.stale || existing?.corrupt)
90
+ ? "; run workflow_reconcile to clear stale/corrupt run locks"
91
+ : "";
92
+ throw new Error(`Workflow ${metadata.operation} lock is already held (${state}): ${lockPath}${recovery}`);
93
+ }
94
+ throw error;
95
+ } finally {
96
+ await fs.rm(tmp, { force: true });
97
+ }
98
+ return async () => {
99
+ const current = await readLock(lockPath);
100
+ if (current?.process?.pid === process.pid && current?.process?.startTime === record.process.startTime) {
101
+ await fs.rm(lockPath, { force: true });
102
+ }
103
+ };
104
+ }
105
+
106
+ async function runLocksForEntry(entry) {
107
+ const locks = {};
108
+ if (entry.kind !== "valid") return locks;
109
+ for (const [name, file] of Object.entries({ run: RUN_LOCK_FILE, apply: APPLY_LOCK_FILE })) {
110
+ const lock = await readLock(path.join(entry.dir, file));
111
+ if (lock) locks[name] = lock;
112
+ }
113
+ return locks;
114
+ }
115
+
116
+ async function clearStaleRunLocks(entry) {
117
+ if (entry.kind !== "valid") return [];
118
+ const cleared = [];
119
+ for (const [operation, file] of Object.entries({ run: RUN_LOCK_FILE, apply: APPLY_LOCK_FILE })) {
120
+ const lockPath = path.join(entry.dir, file);
121
+ const lock = await readLock(lockPath);
122
+ // A corrupt (partial-write) lock has no owning process we can ever match and
123
+ // is never released, so it would permanently wedge acquisition and block
124
+ // cleanup. Treat it like a stale lock and clear it during reconcile.
125
+ // An `unreadable` lock (transient read error — EACCES/EMFILE/EIO) is
126
+ // deliberately NOT cleared here: we could not read it, so it may still be
127
+ // actively held, and deleting it would let a second process resume the run.
128
+ if (lock?.stale || lock?.corrupt) {
129
+ await fs.rm(lockPath, { force: true });
130
+ cleared.push({ operation, path: lockPath, reason: lock?.corrupt ? "corrupt" : "stale" });
131
+ }
132
+ }
133
+ return cleared;
134
+ }
135
+
136
+ function lifecycleRequestPath(dir, type) {
137
+ if (type === "cancel") return path.join(dir, CANCEL_REQUEST_FILE);
138
+ if (type === "pause") return path.join(dir, PAUSE_REQUEST_FILE);
139
+ if (type === "kill") return path.join(dir, KILL_REQUEST_FILE);
140
+ throw new Error(`Unknown workflow lifecycle request type: ${type}`);
141
+ }
142
+
143
+ async function writeLifecycleRequest(dir, type, reason) {
144
+ const request = {
145
+ stateVersion: DURABLE_STATE_VERSION,
146
+ type,
147
+ requestedAt: new Date().toISOString(),
148
+ reason: reason || `${type} requested`,
149
+ process: await currentProcessInfo(),
150
+ };
151
+ await writeJsonAtomic(lifecycleRequestPath(dir, type), request);
152
+ return request;
153
+ }
154
+
155
+ async function readLifecycleRequests(dir) {
156
+ const requests = {};
157
+ for (const type of ["cancel", "pause", "kill"]) {
158
+ const request = await readJsonFile(lifecycleRequestPath(dir, type), undefined);
159
+ if (request) requests[type] = request;
160
+ }
161
+ return requests;
162
+ }
163
+
164
+ export {
165
+ RUN_LOCK_FILE,
166
+ APPLY_LOCK_FILE,
167
+ CLEANUP_LOCK_FILE,
168
+ CANCEL_REQUEST_FILE,
169
+ PAUSE_REQUEST_FILE,
170
+ KILL_REQUEST_FILE,
171
+ lockPathForRun,
172
+ cleanupLockPath,
173
+ readLock,
174
+ acquireWorkflowLock,
175
+ runLocksForEntry,
176
+ clearStaleRunLocks,
177
+ lifecycleRequestPath,
178
+ writeLifecycleRequest,
179
+ readLifecycleRequests,
180
+ };