@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,444 @@
|
|
|
1
|
+
import { parse } from "acorn";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { BUNDLED_WORKFLOW_DIR, GLOBAL_WORKFLOW_DIR, MAX_SOURCE_BYTES } from "./constants.js";
|
|
6
|
+
import { hash } from "./text-json.js";
|
|
7
|
+
|
|
8
|
+
export function literalValue(node) {
|
|
9
|
+
if (!node) throw new Error("Invalid metadata literal");
|
|
10
|
+
if (node.type === "Literal") return node.value;
|
|
11
|
+
if (node.type === "ArrayExpression") return node.elements.map(literalValue);
|
|
12
|
+
if (node.type === "ObjectExpression") {
|
|
13
|
+
const object = {};
|
|
14
|
+
for (const prop of node.properties) {
|
|
15
|
+
if (prop.type !== "Property" || prop.computed) {
|
|
16
|
+
throw new Error("Workflow meta must be a static object literal");
|
|
17
|
+
}
|
|
18
|
+
const key = prop.key.type === "Identifier" ? prop.key.name : prop.key.value;
|
|
19
|
+
object[key] = literalValue(prop.value);
|
|
20
|
+
}
|
|
21
|
+
return object;
|
|
22
|
+
}
|
|
23
|
+
if (node.type === "UnaryExpression" && node.operator === "-" && node.argument.type === "Literal") {
|
|
24
|
+
return -node.argument.value;
|
|
25
|
+
}
|
|
26
|
+
throw new Error("Workflow meta may only contain JSON-compatible literals");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sourceLocationSuffix(nodeOrError) {
|
|
30
|
+
const loc = nodeOrError?.loc?.start ?? nodeOrError?.loc;
|
|
31
|
+
if (!loc || !Number.isInteger(loc.line) || !Number.isInteger(loc.column)) return "";
|
|
32
|
+
return ` at line ${loc.line}, column ${loc.column + 1}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function workflowSourceError(message, nodeOrError) {
|
|
36
|
+
return new Error(`${message}${sourceLocationSuffix(nodeOrError)}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseWorkflowAst(source, purpose) {
|
|
40
|
+
try {
|
|
41
|
+
return parse(source, {
|
|
42
|
+
ecmaVersion: "latest",
|
|
43
|
+
sourceType: "module",
|
|
44
|
+
allowAwaitOutsideFunction: true,
|
|
45
|
+
allowReturnOutsideFunction: true,
|
|
46
|
+
locations: true,
|
|
47
|
+
ranges: false,
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw workflowSourceError(`Workflow ${purpose} parse error: ${error.message}`, error);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function visitAst(node, visitor) {
|
|
55
|
+
if (!node || typeof node !== "object") return;
|
|
56
|
+
visitor(node);
|
|
57
|
+
for (const value of Object.values(node)) {
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
for (const child of value) {
|
|
60
|
+
if (child && typeof child.type === "string") visitAst(child, visitor);
|
|
61
|
+
}
|
|
62
|
+
} else if (value && typeof value.type === "string") {
|
|
63
|
+
visitAst(value, visitor);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fanoutCalleeName(node) {
|
|
69
|
+
if (node?.type === "Identifier" && (node.name === "parallel" || node.name === "pipeline")) return node.name;
|
|
70
|
+
if (node?.type === "MemberExpression" && !node.computed && (node.property?.name === "parallel" || node.property?.name === "pipeline")) return node.property.name;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function propertyLiteralValue(objectNode, name) {
|
|
75
|
+
if (objectNode?.type !== "ObjectExpression") return undefined;
|
|
76
|
+
for (const prop of objectNode.properties ?? []) {
|
|
77
|
+
if (prop.type !== "Property" || prop.computed) continue;
|
|
78
|
+
const key = prop.key.type === "Identifier" ? prop.key.name : prop.key.value;
|
|
79
|
+
if (key === name && prop.value?.type === "Literal") return prop.value.value;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fanoutOptionsOptOut(optionsNode) {
|
|
85
|
+
return propertyLiteralValue(optionsNode, "sequential") === true || propertyLiteralValue(optionsNode, "scoped") === true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function functionRuntimeArity(node) {
|
|
89
|
+
if (node?.type !== "ArrowFunctionExpression" && node?.type !== "FunctionExpression") return null;
|
|
90
|
+
let arity = 0;
|
|
91
|
+
for (const param of node.params ?? []) {
|
|
92
|
+
if (param.type === "AssignmentPattern" || param.type === "RestElement") return arity;
|
|
93
|
+
arity += 1;
|
|
94
|
+
}
|
|
95
|
+
return arity;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function firstReturnedExpression(functionNode) {
|
|
99
|
+
if (!functionNode || functionRuntimeArity(functionNode) === null) return null;
|
|
100
|
+
if (functionNode.body?.type !== "BlockStatement") return functionNode.body;
|
|
101
|
+
const returned = functionNode.body.body?.find((statement) => statement.type === "ReturnStatement");
|
|
102
|
+
return returned?.argument ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function collectSimpleBindings(ast) {
|
|
106
|
+
const bindings = new Map();
|
|
107
|
+
visitAst(ast, (node) => {
|
|
108
|
+
if (node.type !== "VariableDeclaration") return;
|
|
109
|
+
for (const declaration of node.declarations ?? []) {
|
|
110
|
+
if (declaration.id?.type === "Identifier" && declaration.init) bindings.set(declaration.id.name, declaration.init);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return bindings;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function mapCallbackResult(node, bindings, seen) {
|
|
117
|
+
if (node?.type !== "CallExpression") return null;
|
|
118
|
+
const callee = node.callee;
|
|
119
|
+
if (callee?.type !== "MemberExpression" || callee.computed || callee.property?.name !== "map") return null;
|
|
120
|
+
const mapper = resolveExpression(node.arguments?.[0], bindings, seen);
|
|
121
|
+
const returned = firstReturnedExpression(mapper);
|
|
122
|
+
return functionRuntimeArity(returned) !== null ? returned : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveExpression(node, bindings, seen = new Set()) {
|
|
126
|
+
if (node?.type !== "Identifier") return node;
|
|
127
|
+
if (seen.has(node.name)) return node;
|
|
128
|
+
const bound = bindings.get(node.name);
|
|
129
|
+
if (!bound) return node;
|
|
130
|
+
seen.add(node.name);
|
|
131
|
+
return resolveExpression(bound, bindings, seen);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function fanoutCallbacksFromExpression(node, bindings, seen = new Set()) {
|
|
135
|
+
const resolved = resolveExpression(node, bindings, seen);
|
|
136
|
+
if (!resolved) return [];
|
|
137
|
+
if (functionRuntimeArity(resolved) !== null) return [{ node: resolved, index: "0" }];
|
|
138
|
+
if (resolved.type === "ArrayExpression") {
|
|
139
|
+
const callbacks = [];
|
|
140
|
+
for (let index = 0; index < resolved.elements.length; index += 1) {
|
|
141
|
+
const element = resolved.elements[index];
|
|
142
|
+
if (!element) continue;
|
|
143
|
+
if (element.type === "SpreadElement") {
|
|
144
|
+
callbacks.push(...fanoutCallbacksFromExpression(element.argument, bindings, seen));
|
|
145
|
+
} else {
|
|
146
|
+
callbacks.push(...fanoutCallbacksFromExpression(element, bindings, seen).map((entry) => ({ ...entry, index: String(index) })));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return callbacks;
|
|
150
|
+
}
|
|
151
|
+
const mapped = mapCallbackResult(resolved, bindings, seen);
|
|
152
|
+
if (mapped) return [{ node: mapped, index: "map()" }];
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function splitPipelineStages(args) {
|
|
157
|
+
if (args.length === 0) return { stages: [], options: null };
|
|
158
|
+
const last = args[args.length - 1];
|
|
159
|
+
if (last?.type === "ObjectExpression") return { stages: args.slice(0, -1), options: last };
|
|
160
|
+
return { stages: args, options: null };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function lintFanoutCallbacks(ast) {
|
|
164
|
+
const bindings = collectSimpleBindings(ast);
|
|
165
|
+
visitAst(ast, (node) => {
|
|
166
|
+
if (node.type !== "CallExpression") return;
|
|
167
|
+
const helper = fanoutCalleeName(node.callee);
|
|
168
|
+
if (!helper) return;
|
|
169
|
+
if (helper === "parallel") {
|
|
170
|
+
const [thunks, options] = node.arguments ?? [];
|
|
171
|
+
if (fanoutOptionsOptOut(options)) return;
|
|
172
|
+
const bad = fanoutCallbacksFromExpression(thunks, bindings)
|
|
173
|
+
.filter((entry) => functionRuntimeArity(entry.node) === 0);
|
|
174
|
+
if (bad.length > 0) {
|
|
175
|
+
throw workflowSourceError(
|
|
176
|
+
`parallel() callback(s) at index ${bad.map((entry) => entry.index).join(", ")} declare 0 parameters. ` +
|
|
177
|
+
"Declare a scope parameter, e.g. `(api) => api.agent(...)`, for concurrent resume-safe execution. " +
|
|
178
|
+
"Default/rest parameters such as `(api = {}) => ...` or `(...args) => ...` also count as 0 at runtime. " +
|
|
179
|
+
"Use the injected api/context inside fan-out callbacks, or pass `{ sequential: true }` to intentionally run serially.",
|
|
180
|
+
bad[0].node,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const { stages, options } = splitPipelineStages((node.arguments ?? []).slice(1));
|
|
186
|
+
if (fanoutOptionsOptOut(options)) return;
|
|
187
|
+
const bad = [];
|
|
188
|
+
for (let index = 0; index < stages.length; index += 1) {
|
|
189
|
+
for (const entry of fanoutCallbacksFromExpression(stages[index], bindings)) {
|
|
190
|
+
if (functionRuntimeArity(entry.node) === 0) bad.push({ ...entry, index: String(index) });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (bad.length > 0) {
|
|
194
|
+
throw workflowSourceError(
|
|
195
|
+
`pipeline() callback(s) at index ${bad.map((entry) => entry.index).join(", ")} declare 0 parameters. ` +
|
|
196
|
+
"Declare a scope/context parameter, e.g. `(item, context) => context.agent(...)`, for concurrent resume-safe execution. " +
|
|
197
|
+
"Default/rest parameters such as `(context = {}) => ...` or `(...args) => ...` also count as 0 at runtime. " +
|
|
198
|
+
"Use the injected api/context inside fan-out callbacks, or pass `{ sequential: true }` to intentionally run serially.",
|
|
199
|
+
bad[0].node,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function parseWorkflowSource(source) {
|
|
206
|
+
const ast = parseWorkflowAst(source, "source");
|
|
207
|
+
lintFanoutCallbacks(ast);
|
|
208
|
+
|
|
209
|
+
let meta = {};
|
|
210
|
+
let removeStart = -1;
|
|
211
|
+
let removeEnd = -1;
|
|
212
|
+
|
|
213
|
+
for (const node of ast.body) {
|
|
214
|
+
if (node.type === "ImportDeclaration") {
|
|
215
|
+
throw new Error("Workflow scripts may not import modules");
|
|
216
|
+
}
|
|
217
|
+
if (node.type === "ExportDefaultDeclaration" || node.type === "ExportAllDeclaration") {
|
|
218
|
+
// Only `export const meta` is allowed. Any other export (notably `export default`) would be
|
|
219
|
+
// left in the body and then rejected by QuickJS at execution with a cryptic "unsupported
|
|
220
|
+
// keyword: export" — after the user already approved. Fail here (preview/save time) instead.
|
|
221
|
+
throw workflowSourceError(
|
|
222
|
+
"Workflow scripts may only `export const meta = {...}`. Put workflow logic in top-level " +
|
|
223
|
+
"statements ending in `return` (available globals: agent, parallel, pipeline, workflow, drain, " +
|
|
224
|
+
"phase, log, budget, persistArtifacts, inventoryFiles, args) — do not use `export default` or wrap the body in a function.",
|
|
225
|
+
node,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (node.type === "ExportNamedDeclaration") {
|
|
229
|
+
const declaration = node.declaration;
|
|
230
|
+
const declarations = declaration?.declarations ?? [];
|
|
231
|
+
const metaDecl = declarations.find((decl) => decl.id?.name === "meta");
|
|
232
|
+
if (!metaDecl) throw workflowSourceError("Only `export const meta = {...}` is allowed", node);
|
|
233
|
+
const stray = declarations.filter((decl) => decl !== metaDecl);
|
|
234
|
+
if (stray.length > 0) {
|
|
235
|
+
const names = stray
|
|
236
|
+
.map((decl) => decl.id?.name ?? "(complex pattern)")
|
|
237
|
+
.join(", ");
|
|
238
|
+
throw workflowSourceError(
|
|
239
|
+
"Workflow scripts may only `export const meta = {...}`. Rejecting additional exports in the same declaration: " +
|
|
240
|
+
names,
|
|
241
|
+
node,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
meta = literalValue(metaDecl.init);
|
|
245
|
+
removeStart = node.start;
|
|
246
|
+
removeEnd = node.end;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const body = removeStart >= 0 ? source.slice(0, removeStart) + source.slice(removeEnd) : source;
|
|
251
|
+
return { meta, body };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function propertyKeyName(prop) {
|
|
255
|
+
if (!prop || prop.computed) return null;
|
|
256
|
+
if (prop.key?.type === "Identifier") return prop.key.name;
|
|
257
|
+
if (prop.key?.type === "Literal") return prop.key.value;
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function staticNestedWorkflowRefFromCall(node) {
|
|
262
|
+
const first = node.arguments?.[0];
|
|
263
|
+
if (!first) throw workflowSourceError("workflow() nested calls must use a static string name or source", node);
|
|
264
|
+
if (first.type === "Literal" && typeof first.value === "string") {
|
|
265
|
+
return { kind: "string", value: first.value };
|
|
266
|
+
}
|
|
267
|
+
if (first.type !== "ObjectExpression") {
|
|
268
|
+
throw workflowSourceError("workflow() nested calls must use a static string name/source or workflow({ source: \"...\" })", first);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let source = null;
|
|
272
|
+
let name = null;
|
|
273
|
+
for (const prop of first.properties ?? []) {
|
|
274
|
+
if (prop.type !== "Property") {
|
|
275
|
+
throw workflowSourceError("workflow() nested source form must be a static object literal", prop);
|
|
276
|
+
}
|
|
277
|
+
const key = propertyKeyName(prop);
|
|
278
|
+
if (key !== "source" && key !== "name") continue;
|
|
279
|
+
if (prop.value?.type !== "Literal" || typeof prop.value.value !== "string") {
|
|
280
|
+
throw workflowSourceError(`workflow({ ${key} }) must use a static string literal`, prop.value);
|
|
281
|
+
}
|
|
282
|
+
if (key === "source") source = prop.value.value;
|
|
283
|
+
if (key === "name") name = prop.value.value;
|
|
284
|
+
}
|
|
285
|
+
if (Boolean(source) === Boolean(name)) {
|
|
286
|
+
throw workflowSourceError("workflow() nested source form must include exactly one static source or name", first);
|
|
287
|
+
}
|
|
288
|
+
return source !== null ? { kind: "source", value: source } : { kind: "name", value: name };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function staticNestedWorkflowRefsDetailed(source) {
|
|
292
|
+
const refs = [];
|
|
293
|
+
const ast = parseWorkflowAst(source, "nested workflow scan");
|
|
294
|
+
function visit(node) {
|
|
295
|
+
if (!node || typeof node !== "object") return;
|
|
296
|
+
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "workflow") {
|
|
297
|
+
refs.push(staticNestedWorkflowRefFromCall(node));
|
|
298
|
+
}
|
|
299
|
+
for (const value of Object.values(node)) {
|
|
300
|
+
if (Array.isArray(value)) for (const child of value) visit(child);
|
|
301
|
+
else if (value && typeof value === "object" && typeof value.type === "string") visit(value);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
visit(ast);
|
|
305
|
+
return refs;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function staticNestedWorkflowRefs(source) {
|
|
309
|
+
return staticNestedWorkflowRefsDetailed(source).map((ref) => ref.value);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function buildNestedSnapshots(context, source, extensionWorkflowDirs = []) {
|
|
313
|
+
const snapshots = new Map();
|
|
314
|
+
for (const ref of staticNestedWorkflowRefsDetailed(source)) {
|
|
315
|
+
const nested = ref.kind === "source" || (ref.kind === "string" && (ref.value.includes("\n") || ref.value.includes("export const meta")))
|
|
316
|
+
? await resolveWorkflowSource(context, { source: ref.value }, extensionWorkflowDirs)
|
|
317
|
+
: await resolveWorkflowSource(context, { name: ref.value }, extensionWorkflowDirs);
|
|
318
|
+
const nestedHash = hash(nested.source);
|
|
319
|
+
const snapshot = { sourcePath: nested.sourcePath, sourceHash: nestedHash, source: nested.source };
|
|
320
|
+
// Inline sources all share the "<inline>" sourcePath sentinel, so keying by it
|
|
321
|
+
// would let a later inline workflow overwrite an earlier one. Key inline sources
|
|
322
|
+
// purely by hash; only path-backed sources get the path key.
|
|
323
|
+
if (nested.sourcePath !== "<inline>") snapshots.set(nested.sourcePath, snapshot);
|
|
324
|
+
snapshots.set(nestedHash, snapshot);
|
|
325
|
+
}
|
|
326
|
+
return snapshots;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function projectWorkflowDir(context) {
|
|
330
|
+
return path.join(context.worktree || context.directory, ".opencode", "workflows");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function resolveWorkflowSource(context, args, extensionWorkflowDirs = []) {
|
|
334
|
+
if (args.source) {
|
|
335
|
+
if (Buffer.byteLength(args.source, "utf8") > MAX_SOURCE_BYTES) {
|
|
336
|
+
throw new Error(`Workflow source exceeds ${MAX_SOURCE_BYTES} bytes`);
|
|
337
|
+
}
|
|
338
|
+
return { source: args.source, sourcePath: "<inline>" };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let sourcePath = args.scriptPath;
|
|
342
|
+
const fromScriptPath = Boolean(args.scriptPath);
|
|
343
|
+
let unresolvedName = null;
|
|
344
|
+
let searchedRegistries = [];
|
|
345
|
+
if (!sourcePath && args.name) {
|
|
346
|
+
const workflowName = args.name.endsWith(".js") ? args.name.slice(0, -3) : args.name;
|
|
347
|
+
const fileName = workflowFileName(workflowName);
|
|
348
|
+
unresolvedName = workflowName;
|
|
349
|
+
// Resolution order: project > global > extension > bundled. Extension dirs are
|
|
350
|
+
// explicitly-configured trusted host asset dirs (merged ahead of bundled).
|
|
351
|
+
const candidates = [
|
|
352
|
+
{ label: "project", filePath: path.join(projectWorkflowDir(context), fileName) },
|
|
353
|
+
{ label: "global", filePath: path.join(GLOBAL_WORKFLOW_DIR, fileName) },
|
|
354
|
+
...extensionWorkflowDirs.map((dir) => ({ label: "extension", filePath: path.join(dir, fileName) })),
|
|
355
|
+
{ label: "bundled", filePath: path.join(BUNDLED_WORKFLOW_DIR, fileName) },
|
|
356
|
+
];
|
|
357
|
+
searchedRegistries = candidates.map(({ label, filePath }) => `${label}: ${filePath}`);
|
|
358
|
+
for (const { filePath } of candidates) {
|
|
359
|
+
try {
|
|
360
|
+
await fs.access(filePath);
|
|
361
|
+
sourcePath = filePath;
|
|
362
|
+
break;
|
|
363
|
+
} catch {
|
|
364
|
+
// Try the next registry location.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!sourcePath && unresolvedName) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Workflow name "${unresolvedName}" was not found. Searched registries: ${searchedRegistries.join("; ")}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (!sourcePath) throw new Error("Provide `source`, `scriptPath`, or `name`");
|
|
375
|
+
const absolute = path.resolve(context.directory, sourcePath);
|
|
376
|
+
// Explicit scriptPath outside trusted workflow roots fails closed unless allowExternalScriptPath opts in.
|
|
377
|
+
if (fromScriptPath && !isTrustedWorkflowPath(absolute, context, extensionWorkflowDirs) && args.allowExternalScriptPath !== true) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`scriptPath resolves outside trusted workflow roots: ${absolute}. ` +
|
|
380
|
+
"Set allowExternalScriptPath: true to opt in; the resolved path and source hash will appear in the approval preview.",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const stat = await fs.stat(absolute);
|
|
384
|
+
if (!stat.isFile()) throw new Error(`Workflow path is not a file: ${absolute}`);
|
|
385
|
+
if (stat.size > MAX_SOURCE_BYTES) throw new Error(`Workflow source exceeds ${MAX_SOURCE_BYTES} bytes`);
|
|
386
|
+
return { source: await fs.readFile(absolute, "utf8"), sourcePath: absolute };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function hasExplicitWorkflowSource(args = {}) {
|
|
390
|
+
return Boolean(args.source || args.scriptPath || args.name);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export async function resolveWorkflowSourceForStart(context, args, resumeEntry, extensionWorkflowDirs = []) {
|
|
394
|
+
if (!resumeEntry || hasExplicitWorkflowSource(args)) {
|
|
395
|
+
const resolved = await resolveWorkflowSource(context, args, extensionWorkflowDirs);
|
|
396
|
+
const expectedHash = resumeEntry?.state?.sourceHash;
|
|
397
|
+
// jbs3.3 edit-and-resume: by default a resume MUST run the exact approved body — a silent body
|
|
398
|
+
// swap is rejected here (the whole-run source-hash gate). With an explicit `editAndResume: true`
|
|
399
|
+
// opt-in the operator may resume with an EDITED body: the changed source flows through with a NEW
|
|
400
|
+
// sourceHash, which re-keys the approval envelope (approvalHash binds sourceHash) and forces fresh
|
|
401
|
+
// two-phase approval before any lane executes. Lane reuse is content-addressed per lane
|
|
402
|
+
// (event-journal.laneSignature no longer mixes in the whole-file hash), so unchanged lanes still
|
|
403
|
+
// cache-hit at zero re-spend while edited/dependent lanes re-run. Only the BODY may change on an
|
|
404
|
+
// edit-and-resume — the model/budget/authority/maxAgents envelope stays pinned to the prior run.
|
|
405
|
+
if (expectedHash && args.editAndResume !== true && hash(resolved.source) !== expectedHash) throw new Error("resumeRunId source hash mismatch");
|
|
406
|
+
return resolved;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const scriptPath = path.join(resumeEntry.dir, "script.js");
|
|
410
|
+
let stat;
|
|
411
|
+
try {
|
|
412
|
+
stat = await fs.stat(scriptPath);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (error.code === "ENOENT") throw new Error(`Workflow run ${resumeEntry.state?.id ?? "unknown"} cannot resume: missing script.js`);
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
if (!stat.isFile()) throw new Error(`Workflow run ${resumeEntry.state?.id ?? "unknown"} cannot resume: missing script.js`);
|
|
418
|
+
if (stat.size > MAX_SOURCE_BYTES) throw new Error(`Workflow source exceeds ${MAX_SOURCE_BYTES} bytes`);
|
|
419
|
+
const source = await fs.readFile(scriptPath, "utf8");
|
|
420
|
+
const expectedHash = resumeEntry.state?.sourceHash;
|
|
421
|
+
if (expectedHash && hash(source) !== expectedHash) throw new Error("resumeRunId persisted source hash mismatch");
|
|
422
|
+
return { source, sourcePath: resumeEntry.state?.sourcePath ?? scriptPath };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isPathUnderRoot(filePath, root) {
|
|
426
|
+
const resolvedFile = path.resolve(filePath);
|
|
427
|
+
const resolvedRoot = path.resolve(root);
|
|
428
|
+
return resolvedFile === resolvedRoot || resolvedFile.startsWith(`${resolvedRoot}${path.sep}`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function trustedWorkflowRoots(context, extensionWorkflowDirs = []) {
|
|
432
|
+
return [projectWorkflowDir(context), GLOBAL_WORKFLOW_DIR, ...extensionWorkflowDirs, BUNDLED_WORKFLOW_DIR];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function isTrustedWorkflowPath(filePath, context, extensionWorkflowDirs = []) {
|
|
436
|
+
return trustedWorkflowRoots(context, extensionWorkflowDirs).some((root) => isPathUnderRoot(filePath, root));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function workflowFileName(name) {
|
|
440
|
+
if (typeof name !== "string" || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(name)) {
|
|
441
|
+
throw new Error("Workflow name must be a simple slug: letters, numbers, hyphens, max 63 chars");
|
|
442
|
+
}
|
|
443
|
+
return `${name}.js`;
|
|
444
|
+
}
|