@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.
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +746 -0
- package/SECURITY.md +38 -0
- package/commands/repo-bughunt.md +94 -0
- package/commands/repo-review.md +148 -0
- package/commands/workflow-live-gates-release-check.md +143 -0
- package/docs/workflow-plugin.md +400 -0
- package/opencode-workflows.js +5 -0
- package/package.json +86 -0
- package/skills/opencode-workflow-authoring/SKILL.md +180 -0
- package/skills/repo-review-command-protocol/SKILL.md +56 -0
- package/skills/workflow-model-tiering/SKILL.md +57 -0
- package/skills/workflow-plan-review/SKILL.md +91 -0
- package/workflow-kernel/approval-hashing.js +39 -0
- package/workflow-kernel/async-util.js +33 -0
- package/workflow-kernel/audited-shell-policy.js +200 -0
- package/workflow-kernel/authority-policy.js +670 -0
- package/workflow-kernel/budget-accounting.js +142 -0
- package/workflow-kernel/capability-adapter.js +753 -0
- package/workflow-kernel/child-agent-runner.js +1264 -0
- package/workflow-kernel/constants.js +117 -0
- package/workflow-kernel/diagnostics.js +152 -0
- package/workflow-kernel/drain-runtime.js +421 -0
- package/workflow-kernel/errors.js +181 -0
- package/workflow-kernel/event-journal.js +487 -0
- package/workflow-kernel/extension-registry.js +144 -0
- package/workflow-kernel/free-text-redactor.js +91 -0
- package/workflow-kernel/gate-shapes.js +82 -0
- package/workflow-kernel/git-util.js +45 -0
- package/workflow-kernel/index.js +72 -0
- package/workflow-kernel/integration-mode.js +155 -0
- package/workflow-kernel/lane-effort-policy.js +134 -0
- package/workflow-kernel/lifecycle-control.js +608 -0
- package/workflow-kernel/live-gate-probes.js +916 -0
- package/workflow-kernel/notification-toast-cards.js +393 -0
- package/workflow-kernel/notification-toast-policy.js +179 -0
- package/workflow-kernel/notification-toast-scope.js +100 -0
- package/workflow-kernel/notification-toast.js +287 -0
- package/workflow-kernel/path-policy.js +219 -0
- package/workflow-kernel/result-readback.js +106 -0
- package/workflow-kernel/role-template-loading.js +606 -0
- package/workflow-kernel/run-context.js +139 -0
- package/workflow-kernel/run-observability.js +43 -0
- package/workflow-kernel/run-store-fs.js +231 -0
- package/workflow-kernel/run-store-locks.js +180 -0
- package/workflow-kernel/run-store-projections.js +421 -0
- package/workflow-kernel/run-store-rehydrate.js +68 -0
- package/workflow-kernel/run-store-state.js +147 -0
- package/workflow-kernel/run-store-status-format.js +1154 -0
- package/workflow-kernel/run-store-status.js +107 -0
- package/workflow-kernel/sandbox-executor.js +1131 -0
- package/workflow-kernel/session-access.js +56 -0
- package/workflow-kernel/structured-output.js +143 -0
- package/workflow-kernel/test-fix-drain-adapter.js +119 -0
- package/workflow-kernel/text-json.js +136 -0
- package/workflow-kernel/workflow-plugin.js +3017 -0
- package/workflow-kernel/workflow-source.js +444 -0
- package/workflow-kernel/worktree-adapter.js +256 -0
- package/workflow-kernel/worktree-git.js +147 -0
- package/workflows/repo-bughunt.js +362 -0
- package/workflows/repo-cleanup.js +383 -0
- package/workflows/repo-complexity.js +404 -0
- package/workflows/repo-deps.js +457 -0
- package/workflows/repo-modernize.js +395 -0
- package/workflows/repo-perf.js +394 -0
- package/workflows/repo-review.js +831 -0
- package/workflows/repo-security-audit.js +466 -0
- package/workflows/repo-test-gaps.js +377 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
DURABLE_LEDGER_FILES,
|
|
6
|
+
LANE_OUTCOMES,
|
|
7
|
+
MAX_EVENTS,
|
|
8
|
+
MAX_EVENT_MESSAGE_CHARS,
|
|
9
|
+
MAX_JOURNAL_RECORDS,
|
|
10
|
+
} from "./constants.js";
|
|
11
|
+
import {
|
|
12
|
+
extractTextFromError,
|
|
13
|
+
hash,
|
|
14
|
+
hasFunction,
|
|
15
|
+
jsonLine,
|
|
16
|
+
redactDurableValue,
|
|
17
|
+
redactValue,
|
|
18
|
+
stableStringify,
|
|
19
|
+
} from "./text-json.js";
|
|
20
|
+
import { normalizeAgentOptions } from "./authority-policy.js";
|
|
21
|
+
import { classifyLaneError } from "./errors.js";
|
|
22
|
+
import { emitWorkflowDiagnostic } from "./diagnostics.js";
|
|
23
|
+
import { appendFilePrivate, ensurePrivateDir, writeFilePrivate } from "./run-store-fs.js";
|
|
24
|
+
import { notifyRunEventSink } from "./run-observability.js";
|
|
25
|
+
|
|
26
|
+
function laneOutcomeForError(error) {
|
|
27
|
+
if (error?.outcome && LANE_OUTCOMES.includes(error.outcome)) return error.outcome;
|
|
28
|
+
if (error?.code === "WORKFLOW_CANCELLED") return "cancelled";
|
|
29
|
+
if (error?.code === "WORKFLOW_TIMEOUT") return "timeout";
|
|
30
|
+
if (error?.code === "WORKFLOW_BUDGET_STOPPED") return "budget_stopped";
|
|
31
|
+
return "failure";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Failure-class taxonomy carried alongside the LANE_OUTCOMES bucket so diagnostics and
|
|
35
|
+
// status can distinguish a transient-but-exhausted lane (rate-limit/overload that ran out
|
|
36
|
+
// of backed-off retries) or validation_exhausted lane (corrective turns spent) from a
|
|
37
|
+
// terminal one (bad model id, auth, schema with corrective retry disabled). The lane retry
|
|
38
|
+
// loop tags errors it gave up on with `error.laneFailureClass`.
|
|
39
|
+
// everything else is derived from the transient/terminal taxonomy in errors.js. Returns one
|
|
40
|
+
// of: "transient_exhausted" | "validation_exhausted" | "transient" | "terminal".
|
|
41
|
+
function laneFailureClassForError(error) {
|
|
42
|
+
if (typeof error?.laneFailureClass === "string") return error.laneFailureClass;
|
|
43
|
+
return classifyLaneError(error) === "transient" ? "transient" : "terminal";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function laneSignature(run, prompt, resolved) {
|
|
47
|
+
return hash(stableStringify({
|
|
48
|
+
// jbs3.3 (edit-and-resume / prefix reuse): the lane signature is CONTENT-ADDRESSED per lane —
|
|
49
|
+
// it hashes only this lane's own effective inputs (resolved prompt + model + agent/role/system +
|
|
50
|
+
// schema/outputFormat + permission policy + lane options + capability mode + runtimeArgs). The
|
|
51
|
+
// whole-file `run.sourceHash` is deliberately NOT mixed in: a lane's output is fully determined
|
|
52
|
+
// by the inputs above, so an edit to an UNRELATED part of the body must not invalidate this lane.
|
|
53
|
+
// This lets an operator edit a workflow body and resume reusing every lane whose resolved inputs
|
|
54
|
+
// are unchanged (served from the journal cache at zero re-spend) while only the edited lane and
|
|
55
|
+
// its dependents — whose resolved prompt changed because it incorporates the edited upstream
|
|
56
|
+
// output — re-run. Soundness: the journal is keyed by the deterministic callId (loadJournal),
|
|
57
|
+
// not by signature, so two lanes with identical inputs never collide; the signature is a
|
|
58
|
+
// per-callId VALIDATION field. The two-phase approval invariant is preserved independently — an
|
|
59
|
+
// edited body yields a new sourceHash, which changes approvalHash and forces re-approval before
|
|
60
|
+
// any lane (cached or re-run) executes.
|
|
61
|
+
runtimeArgs: run.runtimeArgs,
|
|
62
|
+
prompt,
|
|
63
|
+
resolvedModel: resolved.modelKey,
|
|
64
|
+
agent: resolved.agent,
|
|
65
|
+
role: resolved.role,
|
|
66
|
+
system: resolved.system,
|
|
67
|
+
outputFormat: resolved.outputFormat,
|
|
68
|
+
schema: resolved.schema,
|
|
69
|
+
permissionPolicy: resolved.policy,
|
|
70
|
+
laneOptions: normalizeAgentOptions(resolved.opts),
|
|
71
|
+
capabilityMode: {
|
|
72
|
+
permissions: run.capabilities.permissions,
|
|
73
|
+
structuredOutput: run.capabilities.structuredOutput,
|
|
74
|
+
structuredOutputField: run.capabilities.structuredOutputField,
|
|
75
|
+
},
|
|
76
|
+
// runtimeDiagnostics (SDK/plugin versions, server URL, client-shape booleans) is
|
|
77
|
+
// deliberately excluded: it does not affect a child agent's output and is volatile
|
|
78
|
+
// across processes (the server URL/port is randomized on each OpenCode start), so
|
|
79
|
+
// including it would invalidate the entire resume cache on every cross-process resume.
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function appendEvent(run, event) {
|
|
84
|
+
if (run.eventCount >= MAX_EVENTS) return;
|
|
85
|
+
run.eventCount += 1;
|
|
86
|
+
const record = {
|
|
87
|
+
ts: new Date().toISOString(),
|
|
88
|
+
runId: run.id,
|
|
89
|
+
...redactValue(event, { maxString: MAX_EVENT_MESSAGE_CHARS }),
|
|
90
|
+
};
|
|
91
|
+
run.lastEventAt = record.ts;
|
|
92
|
+
run.lastEventType = typeof event?.type === "string" ? event.type : undefined;
|
|
93
|
+
await appendFilePrivate(path.join(run.dir, "events.jsonl"), jsonLine(record), "utf8");
|
|
94
|
+
await emitWorkflowDiagnostic(run, event);
|
|
95
|
+
notifyRunEventSink(run, record);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function appendJournal(run, record) {
|
|
99
|
+
if (run.journalRecords >= MAX_JOURNAL_RECORDS) {
|
|
100
|
+
throw new Error(`Workflow journal exceeded ${MAX_JOURNAL_RECORDS} records`);
|
|
101
|
+
}
|
|
102
|
+
run.journalRecords += 1;
|
|
103
|
+
await appendFilePrivate(path.join(run.dir, "journal.jsonl"), jsonLine(redactDurableValue(record)), "utf8");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function loadJournal(runDir) {
|
|
107
|
+
const entries = new Map();
|
|
108
|
+
try {
|
|
109
|
+
const content = await fs.readFile(path.join(runDir, "journal.jsonl"), "utf8");
|
|
110
|
+
for (const line of content.split(/\r?\n/)) {
|
|
111
|
+
if (!line.trim()) continue;
|
|
112
|
+
let entry;
|
|
113
|
+
try {
|
|
114
|
+
entry = JSON.parse(line);
|
|
115
|
+
} catch {
|
|
116
|
+
// A crash during the non-atomic appendFile can leave a truncated final line;
|
|
117
|
+
// skip it rather than failing the whole resume.
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (entry.callId) entries.set(entry.callId, entry);
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error.code !== "ENOENT") throw error;
|
|
124
|
+
}
|
|
125
|
+
return entries;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function signatureFallbackScope(callId) {
|
|
129
|
+
const value = String(callId ?? "");
|
|
130
|
+
const agentIndex = value.lastIndexOf("/agent:");
|
|
131
|
+
const laneScope = agentIndex === -1 ? value : value.slice(0, agentIndex);
|
|
132
|
+
const segments = laneScope.split("/").filter(Boolean);
|
|
133
|
+
const lastItemIndex = segments.findLastIndex((segment) => /^item:\d+$/.test(segment));
|
|
134
|
+
if (lastItemIndex > 0) return segments.slice(0, lastItemIndex).join("/");
|
|
135
|
+
return laneScope;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildResumeSignatureIndex(resumeJournal) {
|
|
139
|
+
const index = new Map();
|
|
140
|
+
if (!(resumeJournal instanceof Map)) return index;
|
|
141
|
+
for (const [mapCallId, entry] of resumeJournal.entries()) {
|
|
142
|
+
if (!entry || typeof entry !== "object") continue;
|
|
143
|
+
if (entry.outcome !== "success" || !entry.signatureHash) continue;
|
|
144
|
+
const callId = String(entry.callId ?? mapCallId ?? "");
|
|
145
|
+
if (!callId) continue;
|
|
146
|
+
if (!entry.callId) entry.callId = callId;
|
|
147
|
+
const scope = signatureFallbackScope(callId);
|
|
148
|
+
let byScope = index.get(entry.signatureHash);
|
|
149
|
+
if (!byScope) {
|
|
150
|
+
byScope = new Map();
|
|
151
|
+
index.set(entry.signatureHash, byScope);
|
|
152
|
+
}
|
|
153
|
+
let candidates = byScope.get(scope);
|
|
154
|
+
if (!candidates) {
|
|
155
|
+
candidates = [];
|
|
156
|
+
byScope.set(scope, candidates);
|
|
157
|
+
}
|
|
158
|
+
candidates.push(entry);
|
|
159
|
+
}
|
|
160
|
+
return index;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function markResumeSignatureClaimed(run, callId) {
|
|
164
|
+
if (!run || !callId) return;
|
|
165
|
+
run.resumeSignatureClaims ??= new Set();
|
|
166
|
+
run.resumeSignatureClaims.add(String(callId));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function claimResumeSignatureFallback(run, callId, signatureHash) {
|
|
170
|
+
if (!run || !signatureHash) return null;
|
|
171
|
+
const byScope = run.resumeSignatureIndex?.get(signatureHash);
|
|
172
|
+
if (!byScope) return null;
|
|
173
|
+
const candidates = byScope.get(signatureFallbackScope(callId));
|
|
174
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
175
|
+
run.resumeSignatureClaims ??= new Set();
|
|
176
|
+
for (const entry of candidates) {
|
|
177
|
+
const originalCallId = String(entry?.callId ?? "");
|
|
178
|
+
if (!originalCallId || originalCallId === String(callId)) continue;
|
|
179
|
+
if (run.resumeSignatureClaims.has(originalCallId)) continue;
|
|
180
|
+
run.resumeSignatureClaims.add(originalCallId);
|
|
181
|
+
return entry;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function compactJournal(runDir, entries) {
|
|
187
|
+
const values = entries instanceof Map ? [...entries.values()] : [...(entries ?? [])];
|
|
188
|
+
const journalPath = path.join(runDir, "journal.jsonl");
|
|
189
|
+
const tmp = `${journalPath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
|
190
|
+
try {
|
|
191
|
+
await writeFilePrivate(tmp, values.map((entry) => jsonLine(redactDurableValue(entry))).join(""), "utf8");
|
|
192
|
+
await fs.rename(tmp, journalPath);
|
|
193
|
+
return values.length;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function countNonEmptyLines(filePath) {
|
|
201
|
+
try {
|
|
202
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
203
|
+
let count = 0;
|
|
204
|
+
for (const line of content.split(/\r?\n/)) if (line.trim()) count += 1;
|
|
205
|
+
return count;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error.code === "ENOENT") return 0;
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function ledgerFilePath(runDir, fileName) {
|
|
213
|
+
if (!DURABLE_LEDGER_FILES.includes(fileName) && !/^[a-z0-9_.-]+\.jsonl$/.test(fileName)) {
|
|
214
|
+
throw new Error(`Invalid ledger file name: ${String(fileName)}`);
|
|
215
|
+
}
|
|
216
|
+
return path.join(runDir, fileName);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function appendLedger(runOrDir, fileName, record) {
|
|
220
|
+
const runDir = typeof runOrDir === "string" ? runOrDir : runOrDir?.dir;
|
|
221
|
+
if (!runDir) throw new Error("appendLedger requires a run directory");
|
|
222
|
+
await ensurePrivateDir(runDir);
|
|
223
|
+
await appendFilePrivate(ledgerFilePath(runDir, fileName), jsonLine(redactDurableValue({ ts: new Date().toISOString(), ...record })), "utf8");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function readJsonlLedger(filePath) {
|
|
227
|
+
const records = [];
|
|
228
|
+
try {
|
|
229
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
230
|
+
for (const line of content.split(/\r?\n/)) {
|
|
231
|
+
if (!line.trim()) continue;
|
|
232
|
+
try {
|
|
233
|
+
records.push(JSON.parse(line));
|
|
234
|
+
} catch {
|
|
235
|
+
// A crash during appendFile can truncate the final line; prior complete lines are still valid.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error.code !== "ENOENT") throw error;
|
|
240
|
+
}
|
|
241
|
+
return records;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function ledgerHasCompleted(records, key, keyField = "mutationKey") {
|
|
245
|
+
return records.some((record) => record?.[keyField] === key && record.phase === "completed");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function completedLedgerRecord(records, key, keyField = "mutationKey") {
|
|
249
|
+
for (const record of [...records].reverse()) {
|
|
250
|
+
if (record?.[keyField] === key && record.phase === "completed") return record;
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function latestLedgerRecord(records, key, phase, keyField = "mutationKey") {
|
|
256
|
+
for (const record of [...records].reverse()) {
|
|
257
|
+
if (record?.[keyField] === key && record.phase === phase) return record;
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function incompleteLedgerKeys(records, keyField) {
|
|
263
|
+
const phases = new Map();
|
|
264
|
+
for (const record of records) {
|
|
265
|
+
const key = record?.[keyField];
|
|
266
|
+
if (!key) continue;
|
|
267
|
+
if (!phases.has(key)) phases.set(key, new Set());
|
|
268
|
+
phases.get(key).add(record.phase || record.status || "unknown");
|
|
269
|
+
}
|
|
270
|
+
return [...phases.entries()]
|
|
271
|
+
.filter(([, seen]) => !seen.has("completed") && !seen.has("failed"))
|
|
272
|
+
.map(([key]) => key);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function durableLedgerSummary(runDir) {
|
|
276
|
+
const ledgers = {};
|
|
277
|
+
for (const fileName of DURABLE_LEDGER_FILES) {
|
|
278
|
+
const records = await readJsonlLedger(ledgerFilePath(runDir, fileName));
|
|
279
|
+
const phases = {};
|
|
280
|
+
for (const record of records) {
|
|
281
|
+
const phase = record.phase || record.status || "unknown";
|
|
282
|
+
phases[phase] = (phases[phase] || 0) + 1;
|
|
283
|
+
}
|
|
284
|
+
ledgers[fileName.replace(/\.jsonl$/, "")] = { records: records.length, phases };
|
|
285
|
+
}
|
|
286
|
+
return ledgers;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function appendIntegrationLedger(run, record) {
|
|
290
|
+
await appendLedger(run, "integration-ledger.jsonl", record);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function appendValidationLedger(run, record) {
|
|
294
|
+
await appendLedger(run, "validation-ledger.jsonl", record);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function appendDomainLedger(run, record) {
|
|
298
|
+
await appendLedger(run, "domain-ledger.jsonl", record);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// R16: stable, fixed-length, prefixed idempotency key derived purely from the (already deterministic)
|
|
302
|
+
// mutationKey. Same mutationKey -> same key across crashes/resumes, with no dependence on wall clock,
|
|
303
|
+
// random, or attempt count, so a replay can recognize an already-applied bd mutation.
|
|
304
|
+
function domainMutationIdempotencyKey(mutationKey) {
|
|
305
|
+
return `ocw-idem-${hash(String(mutationKey)).slice(0, 24)}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function runDomainMutation(run, options) {
|
|
309
|
+
const mutationKey = String(options?.mutationKey ?? "");
|
|
310
|
+
if (!mutationKey) throw new Error("runDomainMutation requires mutationKey");
|
|
311
|
+
if (!hasFunction(options, "execute")) throw new Error("runDomainMutation requires execute");
|
|
312
|
+
const ledgerPath = ledgerFilePath(run.dir, "domain-ledger.jsonl");
|
|
313
|
+
const prior = await readJsonlLedger(ledgerPath);
|
|
314
|
+
const completed = completedLedgerRecord(prior, mutationKey);
|
|
315
|
+
if (completed) return { replayed: true, result: completed.result, readback: completed.readback };
|
|
316
|
+
const executed = latestLedgerRecord(prior, mutationKey, "executed");
|
|
317
|
+
if (executed) {
|
|
318
|
+
const readback = hasFunction(options, "readback") ? await options.readback(executed.result) : undefined;
|
|
319
|
+
await appendDomainLedger(run, { phase: "completed", mutationKey, operation: executed.operation ?? options.operation ?? "domain-mutation", result: redactValue(executed.result), readback: redactValue(readback) });
|
|
320
|
+
return { replayed: true, result: executed.result, readback };
|
|
321
|
+
}
|
|
322
|
+
const operation = options.operation || "domain-mutation";
|
|
323
|
+
// R16: derive a deterministic client-side idempotency key from the mutationKey and persist it in
|
|
324
|
+
// the started record BEFORE execute runs. The window between execute returning (the bd mutation
|
|
325
|
+
// already happened) and the executed record being durably appended is a crash point: on resume the
|
|
326
|
+
// executed record is absent, so a naive replay re-runs execute and duplicates the bd resource
|
|
327
|
+
// (a non-idempotent domain create/append would duplicate the resource on replay). Passing this stable
|
|
328
|
+
// key into execute lets the adapter make the underlying create/append idempotent (external-ref
|
|
329
|
+
// dedupe on create; marker dedupe on append), so the re-run is a no-op that returns the existing
|
|
330
|
+
// resource instead of creating a duplicate.
|
|
331
|
+
const idempotencyKey = options.idempotencyKey ? String(options.idempotencyKey) : domainMutationIdempotencyKey(mutationKey);
|
|
332
|
+
await appendDomainLedger(run, { phase: "started", mutationKey, operation, idempotencyKey });
|
|
333
|
+
try {
|
|
334
|
+
const result = await options.execute(idempotencyKey);
|
|
335
|
+
await appendDomainLedger(run, { phase: "executed", mutationKey, operation, idempotencyKey, result: redactValue(result) });
|
|
336
|
+
const readback = hasFunction(options, "readback") ? await options.readback(result) : undefined;
|
|
337
|
+
await appendDomainLedger(run, { phase: "completed", mutationKey, operation, result: redactValue(result), readback: redactValue(readback) });
|
|
338
|
+
return { replayed: false, result, readback };
|
|
339
|
+
} catch (error) {
|
|
340
|
+
await appendDomainLedger(run, { phase: "failed", mutationKey, operation, error: extractTextFromError(error) });
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function stageDomainMutation(run, options) {
|
|
346
|
+
const mutationKey = String(options?.mutationKey ?? "");
|
|
347
|
+
if (!mutationKey) throw new Error("stageDomainMutation requires mutationKey");
|
|
348
|
+
const operation = options.operation || "domain-mutation";
|
|
349
|
+
const ledgerPath = ledgerFilePath(run.dir, "domain-ledger.jsonl");
|
|
350
|
+
const prior = await readJsonlLedger(ledgerPath);
|
|
351
|
+
if (completedLedgerRecord(prior, mutationKey) || latestLedgerRecord(prior, mutationKey, "staged")) {
|
|
352
|
+
return { staged: true, replayed: true, mutationKey, operation, payload: options.payload };
|
|
353
|
+
}
|
|
354
|
+
const record = { phase: "staged", mutationKey, operation, payload: options.payload };
|
|
355
|
+
await appendDomainLedger(run, record);
|
|
356
|
+
return { staged: true, replayed: false, mutationKey, operation, payload: options.payload };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function stagedDomainMutations(records) {
|
|
360
|
+
const staged = new Map();
|
|
361
|
+
for (const record of records) {
|
|
362
|
+
const mutationKey = record?.mutationKey;
|
|
363
|
+
if (!mutationKey) continue;
|
|
364
|
+
if (record.phase === "staged") staged.set(mutationKey, record);
|
|
365
|
+
if (record.phase === "completed") staged.delete(mutationKey);
|
|
366
|
+
}
|
|
367
|
+
return [...staged.values()];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function formatMutationManifestEntries(records) {
|
|
371
|
+
return records
|
|
372
|
+
.map((record) => ({
|
|
373
|
+
mutationKey: record.mutationKey,
|
|
374
|
+
operation: record.operation,
|
|
375
|
+
payloadHash: hash(stableStringify(record.payload ?? null)),
|
|
376
|
+
}))
|
|
377
|
+
.sort((left, right) => String(left.mutationKey).localeCompare(String(right.mutationKey)) || String(left.operation).localeCompare(String(right.operation)));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function domainMutationManifest(records) {
|
|
381
|
+
return formatMutationManifestEntries(stagedDomainMutations(records));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function domainMutationApprovalManifest(records) {
|
|
385
|
+
const approved = new Map();
|
|
386
|
+
for (const record of records) {
|
|
387
|
+
const mutationKey = record?.mutationKey;
|
|
388
|
+
if (!mutationKey) continue;
|
|
389
|
+
if (record.phase === "staged") approved.set(mutationKey, record);
|
|
390
|
+
}
|
|
391
|
+
return formatMutationManifestEntries([...approved.values()]);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function computeDomainMutationHash(manifest) {
|
|
395
|
+
return hash(stableStringify(Array.isArray(manifest) ? manifest : []));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function stagedDomainMutationManifest(runDir) {
|
|
399
|
+
return domainMutationManifest(await readJsonlLedger(ledgerFilePath(runDir, "domain-ledger.jsonl")));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function domainMutationApprovalManifestForRun(runDir) {
|
|
403
|
+
return domainMutationApprovalManifest(await readJsonlLedger(ledgerFilePath(runDir, "domain-ledger.jsonl")));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function executeStagedDomainMutation(record, idempotencyKey, resolveMutationHandler) {
|
|
407
|
+
const operation = String(record.operation ?? "");
|
|
408
|
+
// Domain mutation finalization is owned by trusted extensions, resolved by exact operation name.
|
|
409
|
+
// The resolver is threaded from the caller (which holds the per-pluginContext extension registry),
|
|
410
|
+
// so event-journal stays domain-neutral and no longer imports any domain adapter.
|
|
411
|
+
const handler = typeof resolveMutationHandler === "function" ? resolveMutationHandler(operation) : undefined;
|
|
412
|
+
if (typeof handler !== "function") throw new Error(`Unsupported staged domain mutation operation: ${operation}`);
|
|
413
|
+
// R16: forward the deterministic idempotency key so the adapter can dedupe a crash-resume re-run
|
|
414
|
+
// of a non-idempotent create/append against an already-applied mutation.
|
|
415
|
+
return await handler({ operation: record.operation, idempotencyKey, ...(record.payload ?? {}) });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function finalizeStagedDomainMutations(runDir, state = {}, resolveMutationHandler) {
|
|
419
|
+
const records = await readJsonlLedger(ledgerFilePath(runDir, "domain-ledger.jsonl"));
|
|
420
|
+
const staged = stagedDomainMutations(records);
|
|
421
|
+
if (staged.length === 0) return { finalized: 0, pending: 0, results: [] };
|
|
422
|
+
const run = { id: state.id, dir: runDir };
|
|
423
|
+
const results = [];
|
|
424
|
+
for (const record of staged) {
|
|
425
|
+
const result = await runDomainMutation(run, {
|
|
426
|
+
mutationKey: record.mutationKey,
|
|
427
|
+
operation: record.operation,
|
|
428
|
+
execute: async (idempotencyKey) => await executeStagedDomainMutation(record, idempotencyKey, resolveMutationHandler),
|
|
429
|
+
});
|
|
430
|
+
results.push({ mutationKey: record.mutationKey, operation: record.operation, replayed: result.replayed, result: result.result });
|
|
431
|
+
}
|
|
432
|
+
return { finalized: results.length, pending: 0, results };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function appendApplyLedger(runDir, record) {
|
|
436
|
+
await appendLedger(runDir, "apply-ledger.jsonl", record);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Idempotency source-of-truth for apply: a whole, last-written `completed` record whose
|
|
440
|
+
// diffPlanHash matches the approved plan. The completed-ledger append precedes the
|
|
441
|
+
// state.json=applied write, so a crash in that window leaves on-disk status apply-running
|
|
442
|
+
// (reconciled to interrupted/stale-active). Detecting the completed record here is what
|
|
443
|
+
// lets the apply gate admit those recovery statuses onto the idempotent finalize path
|
|
444
|
+
// instead of wedging the run permanently out of workflow_apply.
|
|
445
|
+
async function applyLedgerHasCompleted(runDir, diffPlanHash) {
|
|
446
|
+
const records = await readJsonlLedger(ledgerFilePath(runDir, "apply-ledger.jsonl"));
|
|
447
|
+
return records.some((record) => record.phase === "completed" && record.diffPlanHash === diffPlanHash);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export {
|
|
451
|
+
laneOutcomeForError,
|
|
452
|
+
laneFailureClassForError,
|
|
453
|
+
laneSignature,
|
|
454
|
+
appendEvent,
|
|
455
|
+
appendJournal,
|
|
456
|
+
loadJournal,
|
|
457
|
+
signatureFallbackScope,
|
|
458
|
+
buildResumeSignatureIndex,
|
|
459
|
+
markResumeSignatureClaimed,
|
|
460
|
+
claimResumeSignatureFallback,
|
|
461
|
+
compactJournal,
|
|
462
|
+
countNonEmptyLines,
|
|
463
|
+
ledgerFilePath,
|
|
464
|
+
appendLedger,
|
|
465
|
+
readJsonlLedger,
|
|
466
|
+
ledgerHasCompleted,
|
|
467
|
+
completedLedgerRecord,
|
|
468
|
+
latestLedgerRecord,
|
|
469
|
+
incompleteLedgerKeys,
|
|
470
|
+
durableLedgerSummary,
|
|
471
|
+
appendIntegrationLedger,
|
|
472
|
+
appendValidationLedger,
|
|
473
|
+
appendDomainLedger,
|
|
474
|
+
domainMutationIdempotencyKey,
|
|
475
|
+
runDomainMutation,
|
|
476
|
+
stageDomainMutation,
|
|
477
|
+
stagedDomainMutations,
|
|
478
|
+
domainMutationManifest,
|
|
479
|
+
domainMutationApprovalManifest,
|
|
480
|
+
computeDomainMutationHash,
|
|
481
|
+
stagedDomainMutationManifest,
|
|
482
|
+
domainMutationApprovalManifestForRun,
|
|
483
|
+
executeStagedDomainMutation,
|
|
484
|
+
finalizeStagedDomainMutations,
|
|
485
|
+
appendApplyLedger,
|
|
486
|
+
applyLedgerHasCompleted,
|
|
487
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
// Trusted extension registry (Stage 1 of the harness extraction).
|
|
5
|
+
//
|
|
6
|
+
// One instance per pluginContext: opencode double-instantiates plugin factories, so keeping
|
|
7
|
+
// the registry per-instance (not a module-level singleton) avoids duplicate-name throws across
|
|
8
|
+
// instantiations. The registry confers TRUST on host-side capabilities — drain adapters and
|
|
9
|
+
// domain-mutation finalizers — and collects extension-owned asset dirs that the caller merges
|
|
10
|
+
// into the existing project/global/extension/bundled resolution. It does NOT resolve assets
|
|
11
|
+
// itself, and it is a strict import-leaf (only node:path) so it can be threaded into
|
|
12
|
+
// sandbox-executor / event-journal without creating an import cycle.
|
|
13
|
+
|
|
14
|
+
const ASSET_KINDS = ["workflows", "commands", "skills"];
|
|
15
|
+
|
|
16
|
+
function asPlainObject(value, label) {
|
|
17
|
+
if (value === undefined || value === null) return {};
|
|
18
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`extension ${label} must be an object`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createExtensionRegistry() {
|
|
23
|
+
const extensions = []; // { id, baseDir }
|
|
24
|
+
const adapters = new Map(); // name -> { ...adapter, __extId }
|
|
25
|
+
const handlers = new Map(); // operation -> { fn, __extId }
|
|
26
|
+
const toolFactories = new Map(); // extId -> tools object | (toolKit) => tools object
|
|
27
|
+
const assetDirs = { workflows: [], commands: [], skills: [] };
|
|
28
|
+
|
|
29
|
+
function register(def, opts = {}) {
|
|
30
|
+
if (!def || typeof def !== "object" || Array.isArray(def)) throw new Error("extension definition must be an object");
|
|
31
|
+
const id = def.id;
|
|
32
|
+
if (typeof id !== "string" || id.length === 0) throw new Error("extension definition requires a non-empty string `id`");
|
|
33
|
+
|
|
34
|
+
// Per-id idempotency: re-registering the same extension (e.g. the same module imported twice)
|
|
35
|
+
// is a no-op. Cross-extension name clashes below still throw.
|
|
36
|
+
if (extensions.some((e) => e.id === id)) return;
|
|
37
|
+
|
|
38
|
+
const baseDir = opts.baseDir;
|
|
39
|
+
|
|
40
|
+
const drainAdapters = asPlainObject(def.drainAdapters, `${id}.drainAdapters`);
|
|
41
|
+
for (const [name, adapter] of Object.entries(drainAdapters)) {
|
|
42
|
+
const existing = adapters.get(name);
|
|
43
|
+
if (existing && existing.__extId !== id) {
|
|
44
|
+
throw new Error(`duplicate drain adapter "${name}" (already registered by extension "${existing.__extId}")`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const mutationHandlers = asPlainObject(def.mutationHandlers, `${id}.mutationHandlers`);
|
|
49
|
+
for (const [op, fn] of Object.entries(mutationHandlers)) {
|
|
50
|
+
if (typeof fn !== "function") throw new Error(`mutation handler "${op}" must be a function`);
|
|
51
|
+
const existing = handlers.get(op);
|
|
52
|
+
if (existing && existing.__extId !== id) {
|
|
53
|
+
throw new Error(`duplicate mutation operation "${op}" (already registered by extension "${existing.__extId}")`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const declaredDirs = asPlainObject(def.assetDirs, `${id}.assetDirs`);
|
|
58
|
+
for (const kind of ASSET_KINDS) {
|
|
59
|
+
const rel = declaredDirs[kind];
|
|
60
|
+
if (rel === undefined || rel === null) continue;
|
|
61
|
+
if (typeof rel !== "string") throw new Error(`assetDirs.${kind} must be a string path`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const toolsDef = def.tools;
|
|
65
|
+
if (toolsDef !== undefined && toolsDef !== null) {
|
|
66
|
+
const ok = typeof toolsDef === "function" || (typeof toolsDef === "object" && !Array.isArray(toolsDef));
|
|
67
|
+
if (!ok) throw new Error(`extension ${id}.tools must be an object map or a (toolKit) => object factory`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// All validation passed — commit (freeze adapter definitions so guests cannot mutate them).
|
|
71
|
+
for (const [name, adapter] of Object.entries(drainAdapters)) {
|
|
72
|
+
adapters.set(name, Object.freeze({ ...adapter, __extId: id }));
|
|
73
|
+
}
|
|
74
|
+
for (const [op, fn] of Object.entries(mutationHandlers)) {
|
|
75
|
+
handlers.set(op, { fn, __extId: id });
|
|
76
|
+
}
|
|
77
|
+
for (const kind of ASSET_KINDS) {
|
|
78
|
+
const rel = declaredDirs[kind];
|
|
79
|
+
if (typeof rel !== "string") continue;
|
|
80
|
+
assetDirs[kind].push(path.isAbsolute(rel) ? rel : path.join(baseDir ?? ".", rel));
|
|
81
|
+
}
|
|
82
|
+
if (toolsDef !== undefined && toolsDef !== null) toolFactories.set(id, toolsDef);
|
|
83
|
+
extensions.push(Object.freeze({ id, baseDir }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadExtensions(paths = [], { configDir, importer = (p) => import(pathToFileURL(p).href) } = {}) {
|
|
87
|
+
for (const entry of paths) {
|
|
88
|
+
// Extension MODULE paths resolve relative to the opencode config dir (where opencode.json
|
|
89
|
+
// lives); the extension's own asset dirs resolve relative to the module's dir (baseDir below).
|
|
90
|
+
const modulePath = path.isAbsolute(entry) ? entry : path.join(configDir ?? ".", entry);
|
|
91
|
+
let mod;
|
|
92
|
+
try {
|
|
93
|
+
mod = await importer(modulePath);
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
throw new Error(`failed to load workflow extension ${modulePath}: ${cause?.message ?? cause}`, { cause });
|
|
96
|
+
}
|
|
97
|
+
let def = mod?.default ?? mod;
|
|
98
|
+
if (typeof def === "function") def = await def();
|
|
99
|
+
try {
|
|
100
|
+
register(def, { baseDir: path.dirname(modulePath) });
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
throw new Error(`invalid workflow extension ${modulePath}: ${cause?.message ?? cause}`, { cause });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
register,
|
|
109
|
+
loadExtensions,
|
|
110
|
+
drainAdapter(name) {
|
|
111
|
+
const adapter = adapters.get(name);
|
|
112
|
+
if (!adapter) return undefined;
|
|
113
|
+
const { __extId, ...rest } = adapter;
|
|
114
|
+
return rest;
|
|
115
|
+
},
|
|
116
|
+
mutationHandler: (op) => handlers.get(op)?.fn,
|
|
117
|
+
// Resolve every extension's contributed tools into one merged { name: ToolDefinition } map.
|
|
118
|
+
// A factory form is called with `toolKit` (the kernel injects tool/schema/pluginContext/guards
|
|
119
|
+
// so extensions need no @opencode-ai/plugin dependency). Fail closed on a reserved (core) name
|
|
120
|
+
// or a cross-extension duplicate. Import-leaf: no @opencode-ai/plugin import here.
|
|
121
|
+
tools: (toolKit, reservedNames = []) => {
|
|
122
|
+
const reserved = new Set(reservedNames);
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const [id, def] of toolFactories) {
|
|
125
|
+
const resolved = typeof def === "function" ? def(toolKit) : def;
|
|
126
|
+
if (!resolved || typeof resolved !== "object" || Array.isArray(resolved)) {
|
|
127
|
+
throw new Error(`extension "${id}" tools factory must return an object map of tools`);
|
|
128
|
+
}
|
|
129
|
+
for (const [name, toolDef] of Object.entries(resolved)) {
|
|
130
|
+
if (reserved.has(name)) throw new Error(`extension "${id}" tool "${name}" collides with a reserved core tool name`);
|
|
131
|
+
if (Object.hasOwn(out, name)) throw new Error(`duplicate extension tool "${name}" (already contributed by another extension)`);
|
|
132
|
+
out[name] = toolDef;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
},
|
|
137
|
+
assetDirs: () => ({
|
|
138
|
+
workflows: [...assetDirs.workflows],
|
|
139
|
+
commands: [...assetDirs.commands],
|
|
140
|
+
skills: [...assetDirs.skills],
|
|
141
|
+
}),
|
|
142
|
+
listExtensions: () => extensions.map((e) => ({ ...e })),
|
|
143
|
+
};
|
|
144
|
+
}
|