@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,1131 @@
|
|
|
1
|
+
// sandbox-executor.js — the QuickJS sandbox + host-op dispatch boundary extracted from the
|
|
2
|
+
// workflow orchestrator (opencode-workflows-96b, stage 2 of the staged RunContext split).
|
|
3
|
+
//
|
|
4
|
+
// Owns executeSandbox (the deterministic QuickJS VM lifecycle, the workflow prelude, the
|
|
5
|
+
// guest-deadline arming, and the host bridge) plus the host-op dispatch surface it routes
|
|
6
|
+
// to: runNestedWorkflow, runHostDrain (and its drain lane/integration plumbing), and
|
|
7
|
+
// cancelFanoutSiblings. The "agent" op delegates to runChildAgent (child-agent-runner.js).
|
|
8
|
+
//
|
|
9
|
+
// Coupling surface: each entry takes the shared mutable {@link RunContext} `run`. A `deps`
|
|
10
|
+
// bundle injects the orchestrator-resident primitives this boundary and runChildAgent need
|
|
11
|
+
// (concurrency slots, abort/cancel guards, durable-lifecycle polling, dirty-worktree
|
|
12
|
+
// salvage, lane-timeout alias resolution) so this module imports only leaf kernel modules
|
|
13
|
+
// plus child-agent-runner.js, never workflow-plugin.js. Import graph stays acyclic:
|
|
14
|
+
// workflow-plugin.js -> sandbox-executor.js -> child-agent-runner.js.
|
|
15
|
+
//
|
|
16
|
+
// @typedef {import("./run-context.js").RunContext} RunContext
|
|
17
|
+
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import fs from "node:fs/promises";
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
22
|
+
import { newQuickJSAsyncWASMModule } from "quickjs-emscripten";
|
|
23
|
+
import { shouldInterruptAfterDeadline } from "quickjs-emscripten-core";
|
|
24
|
+
import { drain as runDrainRuntime } from "./drain-runtime.js";
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_GUEST_DEADLINE_MS,
|
|
27
|
+
HOST_CALL_MARGIN,
|
|
28
|
+
HOST_CALLS_PER_MAX_AGENT,
|
|
29
|
+
MAX_EVENT_MESSAGE_CHARS,
|
|
30
|
+
MAX_HOST_CALLS,
|
|
31
|
+
MAX_PENDING_JOB_DRAIN_ITERATIONS,
|
|
32
|
+
MAX_STATUS_STRING_CHARS,
|
|
33
|
+
} from "./constants.js";
|
|
34
|
+
import { NON_DRY_DRAIN_REQUIRED_GATES } from "./authority-policy.js";
|
|
35
|
+
import {
|
|
36
|
+
extractTextFromError,
|
|
37
|
+
hash,
|
|
38
|
+
jsonPreview,
|
|
39
|
+
stableStringify,
|
|
40
|
+
truncateText,
|
|
41
|
+
} from "./text-json.js";
|
|
42
|
+
import { WorkflowCancelledError } from "./errors.js";
|
|
43
|
+
import { assertResultSize } from "./structured-output.js";
|
|
44
|
+
import { abortChild, rejectWaitingAgents } from "./lifecycle-control.js";
|
|
45
|
+
import {
|
|
46
|
+
compactLiveGateStatus,
|
|
47
|
+
liveGateProbeArgsForNames,
|
|
48
|
+
liveGateReport,
|
|
49
|
+
nonVerifiedGateSummaries,
|
|
50
|
+
} from "./capability-adapter.js";
|
|
51
|
+
import { budgetSnapshot, checkBudgetBeforeLaunch } from "./budget-accounting.js";
|
|
52
|
+
import {
|
|
53
|
+
appendEvent,
|
|
54
|
+
appendIntegrationLedger,
|
|
55
|
+
appendValidationLedger,
|
|
56
|
+
} from "./event-journal.js";
|
|
57
|
+
import { recordRecentLog } from "./run-observability.js";
|
|
58
|
+
import {
|
|
59
|
+
safeProjectionName,
|
|
60
|
+
ensurePrivateDir,
|
|
61
|
+
writeFilePrivate,
|
|
62
|
+
writeLaneProjection,
|
|
63
|
+
writeState,
|
|
64
|
+
} from "./run-store-status.js";
|
|
65
|
+
import { maybeShowWorkflowProgressToast } from "./notification-toast.js";
|
|
66
|
+
import {
|
|
67
|
+
parseWorkflowSource,
|
|
68
|
+
resolveWorkflowSource,
|
|
69
|
+
} from "./workflow-source.js";
|
|
70
|
+
import { pathContains } from "./path-policy.js";
|
|
71
|
+
import {
|
|
72
|
+
findIntegrationLane,
|
|
73
|
+
runChildAgent,
|
|
74
|
+
} from "./child-agent-runner.js";
|
|
75
|
+
|
|
76
|
+
const MAX_FANOUT_DIAGNOSTICS = 50;
|
|
77
|
+
|
|
78
|
+
const PENDING_HOST_OP_SETTLE_TIMEOUT_MS = 1_000;
|
|
79
|
+
|
|
80
|
+
let sandboxHostOpTestHook;
|
|
81
|
+
let quickJSAsyncModulePromise;
|
|
82
|
+
|
|
83
|
+
function maxHostCallsForRun(run) {
|
|
84
|
+
const maxAgents = Number.isInteger(run?.maxAgents) && run.maxAgents > 0 ? run.maxAgents : 0;
|
|
85
|
+
return Math.max(MAX_HOST_CALLS, maxAgents * HOST_CALLS_PER_MAX_AGENT + HOST_CALL_MARGIN);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function quickJSAsyncModule() {
|
|
89
|
+
if (!quickJSAsyncModulePromise) {
|
|
90
|
+
// Memoize the load Promise so a successful WASM module is instantiated once and reused. But
|
|
91
|
+
// guard the rejection path: if this first load fails (transient resource/memory pressure, a
|
|
92
|
+
// filesystem hiccup loading the .wasm asset, a one-off emscripten instantiation failure), a
|
|
93
|
+
// memoized rejected Promise would permanently disable ALL workflow execution for the lifetime
|
|
94
|
+
// of the long-running plugin host. Reset the cache in a .catch() before rethrowing so the next
|
|
95
|
+
// call retries a fresh load, while a resolved module stays memoized.
|
|
96
|
+
quickJSAsyncModulePromise = newQuickJSAsyncWASMModule();
|
|
97
|
+
quickJSAsyncModulePromise.catch(() => {
|
|
98
|
+
quickJSAsyncModulePromise = undefined;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return quickJSAsyncModulePromise;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function newSandboxContext() {
|
|
105
|
+
const module = await quickJSAsyncModule();
|
|
106
|
+
const runtime = module.newRuntime();
|
|
107
|
+
return runtime.newContext();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function recordFanoutDroppedDiagnostic(run, payload) {
|
|
111
|
+
const diagnostics = run.diagnostics ??= {};
|
|
112
|
+
const existing = Array.isArray(diagnostics.fanoutDroppedFailures) ? diagnostics.fanoutDroppedFailures : [];
|
|
113
|
+
const record = {
|
|
114
|
+
scope: truncateText(String(payload?.scope ?? ""), MAX_STATUS_STRING_CHARS),
|
|
115
|
+
errorSummary: truncateText(String(payload?.error ?? ""), MAX_STATUS_STRING_CHARS),
|
|
116
|
+
};
|
|
117
|
+
if (existing.length < MAX_FANOUT_DIAGNOSTICS) {
|
|
118
|
+
diagnostics.fanoutDroppedFailures = [...existing, record];
|
|
119
|
+
} else {
|
|
120
|
+
diagnostics.fanoutDroppedFailures = existing;
|
|
121
|
+
diagnostics.fanoutDroppedFailuresTruncated = (diagnostics.fanoutDroppedFailuresTruncated ?? 0) + 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- nested workflow dispatch (host "workflow" op) ---
|
|
126
|
+
|
|
127
|
+
async function runNestedWorkflow(pluginContext, toolContext, run, payload, deps) {
|
|
128
|
+
if (run.nestingDepth >= 1) throw new Error("Nested workflow recursion is rejected; only one nesting level is supported");
|
|
129
|
+
const requested = payload?.source ? { source: payload.source } : { name: payload?.name };
|
|
130
|
+
if (!requested.source && !requested.name) throw new Error("Nested workflow() requires a static workflow name or source");
|
|
131
|
+
// Forward the trusted extension workflow dirs so a nested workflow resolves to the SAME path
|
|
132
|
+
// here (runtime) as in buildNestedSnapshots (approval) — the snapshot match is keyed by sourcePath.
|
|
133
|
+
const { source, sourcePath } = await resolveWorkflowSource(
|
|
134
|
+
toolContext,
|
|
135
|
+
requested,
|
|
136
|
+
pluginContext.workflowExtensionRegistry?.assetDirs()?.workflows ?? [],
|
|
137
|
+
);
|
|
138
|
+
const sourceHash = hash(source);
|
|
139
|
+
// Inline sources share the "<inline>" sentinel path, so the path lookup is ambiguous
|
|
140
|
+
// across distinct inline workflows; resolve them purely by content hash.
|
|
141
|
+
const snapshot = (sourcePath !== "<inline>" && run.nestedSnapshots.get(sourcePath)) || run.nestedSnapshots.get(sourceHash);
|
|
142
|
+
if (snapshot && snapshot.sourceHash !== sourceHash) throw new Error(`Nested workflow source changed after approval: ${sourcePath}`);
|
|
143
|
+
if (!snapshot) throw new Error(`Nested workflow was not part of approved static snapshot: ${sourcePath}`);
|
|
144
|
+
const { body } = parseWorkflowSource(source);
|
|
145
|
+
await appendEvent(run, { type: "workflow.nested.started", sourcePath, sourceHash });
|
|
146
|
+
run.nestingDepth += 1;
|
|
147
|
+
try {
|
|
148
|
+
const output = await executeSandbox(pluginContext, toolContext, run, body, payload?.args ?? null, deps);
|
|
149
|
+
await appendEvent(run, { type: "workflow.nested.completed", sourcePath, sourceHash });
|
|
150
|
+
return output;
|
|
151
|
+
} finally {
|
|
152
|
+
run.nestingDepth -= 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- fanout cancellation (host "fanoutCancel" op) ---
|
|
157
|
+
|
|
158
|
+
async function cancelFanoutSiblings(pluginContext, run, scope, reason = "failFast sibling cancellation") {
|
|
159
|
+
const prefix = `${scope}/`;
|
|
160
|
+
run.cancelledFanoutScopes.add(scope);
|
|
161
|
+
const error = new WorkflowCancelledError(reason);
|
|
162
|
+
rejectWaitingAgents(run, error, (waiter) => waiter.callId === scope || String(waiter.callId ?? "").startsWith(prefix));
|
|
163
|
+
const aborts = [];
|
|
164
|
+
for (const [callId, lane] of run.activeLaneAbortControllers ?? []) {
|
|
165
|
+
if (callId !== scope && !callId.startsWith(prefix)) continue;
|
|
166
|
+
lane.abortController.abort();
|
|
167
|
+
if (lane.childID && lane.childAbortRequested !== true) {
|
|
168
|
+
lane.childAbortRequested = true;
|
|
169
|
+
aborts.push(abortChild(pluginContext, lane.childID, lane.directory));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await Promise.allSettled(aborts);
|
|
173
|
+
await appendEvent(run, { type: "fanout.cancel_siblings", scope, reason, activeAbortCount: aborts.length });
|
|
174
|
+
await writeState(run);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- drain host primitive (host "drain" op) ---
|
|
178
|
+
|
|
179
|
+
async function createDrainAdapter(pluginContext, toolContext, run, options) {
|
|
180
|
+
const adapterName = options.adapter;
|
|
181
|
+
// Test seam (per-pluginContext): the proof-of-concept resolution path the registry generalizes.
|
|
182
|
+
const testAdapterFactory = pluginContext.__workflowDrainAdapters?.[adapterName];
|
|
183
|
+
if (testAdapterFactory) return await testAdapterFactory({ pluginContext, toolContext, run, options });
|
|
184
|
+
// Production: resolve the adapter through the trusted extension registry. Domain adapters are host
|
|
185
|
+
// code registered by an explicitly-configured extension — the core kernel ships none itself.
|
|
186
|
+
const registration = pluginContext.workflowExtensionRegistry?.drainAdapter(adapterName);
|
|
187
|
+
if (!registration || typeof registration.createAdapter !== "function") {
|
|
188
|
+
throw new Error(`Unsupported drain adapter: ${String(adapterName)} (no extension registered this adapter)`);
|
|
189
|
+
}
|
|
190
|
+
return await registration.createAdapter({ pluginContext, toolContext, run, options });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function drainLaneItemId(packet, attemptContext = {}) {
|
|
194
|
+
return String(attemptContext.itemId ?? packet?.itemId ?? packet?.item?.id ?? "");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function drainLaneCallId(packet, attemptContext = {}) {
|
|
198
|
+
const id = drainLaneItemId(packet, attemptContext) || "item";
|
|
199
|
+
const attempt = Number.isInteger(attemptContext.attempt) ? attemptContext.attempt : 1;
|
|
200
|
+
return `drain:${safeProjectionName(id)}:${hash(id).slice(0, 8)}:attempt:${attempt}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const DRAIN_LANE_REPORT_SCHEMA = {
|
|
204
|
+
type: "object",
|
|
205
|
+
additionalProperties: true,
|
|
206
|
+
required: ["itemId", "outcome", "summary", "readyForIntegration", "filesChanged", "commandsRun", "acceptanceEvidence", "residualRisks", "followups"],
|
|
207
|
+
properties: {
|
|
208
|
+
itemId: { type: "string", minLength: 1 },
|
|
209
|
+
outcome: { enum: ["implemented", "blocked", "needs-research", "failed", "no-op"] },
|
|
210
|
+
summary: { type: "string" },
|
|
211
|
+
readyForIntegration: { type: "boolean" },
|
|
212
|
+
filesChanged: { type: "array", items: { type: "string" } },
|
|
213
|
+
commandsRun: { type: "array", items: { type: "string" } },
|
|
214
|
+
acceptanceEvidence: { type: "array", items: { type: "string" } },
|
|
215
|
+
residualRisks: { type: "array", items: { type: "string" } },
|
|
216
|
+
followups: { type: "array", items: { type: "object" } },
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
function drainLanePrompt(packet, attemptContext = {}) {
|
|
221
|
+
const item = packet?.item ?? attemptContext.item;
|
|
222
|
+
const instructions = Array.isArray(packet?.instructions) ? packet.instructions : [];
|
|
223
|
+
return [
|
|
224
|
+
"You are implementing one item for a host-owned drain workflow.",
|
|
225
|
+
"Implement only the assigned item. Edit files only in your assigned worktree.",
|
|
226
|
+
"Make the smallest plausible edit batch, then stop and return the structured LaneReport immediately.",
|
|
227
|
+
"Do not keep exploring or polishing after the first coherent fix. If unsure, blocked, or only partially done, return outcome 'failed' or 'needs-research' with readyForIntegration false and concrete next steps.",
|
|
228
|
+
"Do not run tests; the controller owns bounded validation after your report.",
|
|
229
|
+
"Do not mutate domain state directly; the controller owns all domain reads/writes. (The adapter instructions below list any domain-specific command prohibitions.)",
|
|
230
|
+
"Return exactly the requested structured LaneReport. Set readyForIntegration true only when your changes are complete and safe to merge.",
|
|
231
|
+
instructions.length ? `Adapter instructions:\n${instructions.map((item) => `- ${item}`).join("\n")}` : undefined,
|
|
232
|
+
attemptContext.priorValidation ? `Previous validation:\n${jsonPreview(attemptContext.priorValidation)}` : undefined,
|
|
233
|
+
`Assigned item:\n${jsonPreview(item, 6_000)}`,
|
|
234
|
+
].filter(Boolean).join("\n\n");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function failedDrainLaneReport(packet, attemptContext = {}, reason = "drain lane failed", salvage = undefined) {
|
|
238
|
+
const itemId = drainLaneItemId(packet, attemptContext) || "unknown";
|
|
239
|
+
const summary = truncateText(reason, MAX_STATUS_STRING_CHARS);
|
|
240
|
+
const changedFiles = Array.isArray(salvage?.changedFiles) ? salvage.changedFiles.map((entry) => entry.path).filter(Boolean) : [];
|
|
241
|
+
return {
|
|
242
|
+
itemId,
|
|
243
|
+
outcome: "failed",
|
|
244
|
+
summary,
|
|
245
|
+
readyForIntegration: false,
|
|
246
|
+
filesChanged: changedFiles,
|
|
247
|
+
commandsRun: [],
|
|
248
|
+
acceptanceEvidence: [],
|
|
249
|
+
residualRisks: [summary, salvage?.dirty ? "Dirty worktree was preserved for salvage; no structured child report was received." : undefined].filter(Boolean),
|
|
250
|
+
followups: [],
|
|
251
|
+
salvage,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function drainLaneReadyForIntegration(laneReport) {
|
|
256
|
+
return laneReport?.readyForIntegration === true && laneReport.outcome === "implemented";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function nonVerifiedDrainBlockers(gateStatus) {
|
|
260
|
+
return Object.entries(gateStatus).filter(([, gate]) => gate?.verified !== true)
|
|
261
|
+
.map(([name, gate]) => `${name}=${gate?.state ?? "missing"}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function drainGateStatus(pluginContext, toolContext, options = {}) {
|
|
265
|
+
// Probe only the gates this run actually requires (the resolved authority floor); a drain whose
|
|
266
|
+
// authority requires no gates needs no probing. Gate-union/floor model (invariant #5).
|
|
267
|
+
const requiredGates = Array.isArray(options.requiredGates) && options.requiredGates.length
|
|
268
|
+
? options.requiredGates
|
|
269
|
+
: NON_DRY_DRAIN_REQUIRED_GATES;
|
|
270
|
+
const probeRequired = options.probeRequired === true;
|
|
271
|
+
const probeOptions = liveGateProbeArgsForNames(requiredGates);
|
|
272
|
+
for (const key of Object.keys(probeOptions)) {
|
|
273
|
+
if (key !== "format") probeOptions[key] = probeRequired;
|
|
274
|
+
}
|
|
275
|
+
const report = JSON.parse(await liveGateReport(pluginContext, toolContext, probeOptions));
|
|
276
|
+
return compactLiveGateStatus(report, requiredGates);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function markDrainLaneIntegration(run, callId, laneReport, acceptedForIntegration, reason) {
|
|
280
|
+
const lane = findIntegrationLane(run, callId);
|
|
281
|
+
if (!lane) return undefined;
|
|
282
|
+
lane.itemId = laneReport.itemId;
|
|
283
|
+
lane.acceptedForIntegration = acceptedForIntegration;
|
|
284
|
+
lane.acceptanceReason = reason;
|
|
285
|
+
await appendIntegrationLedger(run, {
|
|
286
|
+
phase: acceptedForIntegration ? "lane-accepted" : "lane-rejected",
|
|
287
|
+
callId,
|
|
288
|
+
itemId: laneReport.itemId,
|
|
289
|
+
reason,
|
|
290
|
+
});
|
|
291
|
+
await writeLaneProjection(run, callId, { integrationLane: lane });
|
|
292
|
+
await writeState(run);
|
|
293
|
+
return lane;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Backing implementation for globalThis.drain. Thin drain workflows call this host
|
|
297
|
+
// primitive so domain reads/mutations, validation, and dry proof
|
|
298
|
+
// stay in trusted controller code rather than script-body prompt plumbing.
|
|
299
|
+
async function runHostDrain(pluginContext, toolContext, run, payload, deps) {
|
|
300
|
+
const throwIfAborted = typeof deps?.throwIfAborted === "function" ? deps.throwIfAborted : () => {};
|
|
301
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("drain() requires an options object");
|
|
302
|
+
if (typeof payload.adapter !== "string" || payload.adapter === "") throw new Error("drain() requires a trusted adapter name");
|
|
303
|
+
const {
|
|
304
|
+
adapter: adapterName,
|
|
305
|
+
runLane,
|
|
306
|
+
integrate,
|
|
307
|
+
gateStatus: _guestGateStatus,
|
|
308
|
+
gates: _guestGates,
|
|
309
|
+
...runtimeOptions
|
|
310
|
+
} = payload;
|
|
311
|
+
if (runLane !== undefined || integrate !== undefined) throw new Error("drain() lane execution is host-owned and cannot be supplied by workflow source");
|
|
312
|
+
const drainLaneTimeoutMs = deps.laneTimeoutAliasValue(runtimeOptions, "drain") ?? run.laneTimeoutMs;
|
|
313
|
+
const dryRun = payload.dryRun === true;
|
|
314
|
+
const rawAdapter = await createDrainAdapter(pluginContext, toolContext, run, { ...runtimeOptions, adapter: adapterName });
|
|
315
|
+
// Live-gate enforcement is generic to every drain and keyed on the ADAPTER's declared required
|
|
316
|
+
// gates (the gate-union floor). Guest workflow source cannot supply gateStatus/gates: those fields
|
|
317
|
+
// are host-only evidence and are stripped before adapter construction and runtime execution. Non-dry
|
|
318
|
+
// drains must have all required gates verified before any domain mutation or lane launch. An adapter
|
|
319
|
+
// that declares no gates (a pure test double) needs no probing.
|
|
320
|
+
const requiredGates = Array.isArray(rawAdapter.requiredGates) ? rawAdapter.requiredGates : [];
|
|
321
|
+
const gateStatus = requiredGates.length ? await drainGateStatus(pluginContext, toolContext, { probeRequired: !dryRun, requiredGates }) : undefined;
|
|
322
|
+
if (gateStatus) {
|
|
323
|
+
run.diagnostics.drainLiveGates = gateStatus;
|
|
324
|
+
const nonVerified = nonVerifiedGateSummaries(gateStatus);
|
|
325
|
+
const blockers = nonVerifiedDrainBlockers(gateStatus);
|
|
326
|
+
await appendEvent(run, {
|
|
327
|
+
type: "drain.live_gates",
|
|
328
|
+
adapter: adapterName,
|
|
329
|
+
dryRun,
|
|
330
|
+
gateStatus,
|
|
331
|
+
verified: nonVerified.length === 0,
|
|
332
|
+
blockers,
|
|
333
|
+
});
|
|
334
|
+
await writeState(run);
|
|
335
|
+
if (!dryRun && blockers.length > 0) {
|
|
336
|
+
throw new Error(`Non-dry drain requires verified live gates before domain mutation or lane launch: ${blockers.join(", ")}. Run a dry-run or fix the reported live gates before retrying.`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
await appendEvent(run, { type: "drain.started", adapter: adapterName, dryRun });
|
|
340
|
+
const adapter = {
|
|
341
|
+
...rawAdapter,
|
|
342
|
+
async validate(item, integrationState, context = {}) {
|
|
343
|
+
const laneReport = context.laneReport;
|
|
344
|
+
// R11-followup-2 (opencode-workflows-qga): hand the adapter the CONTROLLER ground truth for
|
|
345
|
+
// doc-vs-code scope — the integrated diff's git name-only paths the lane cannot spoof — instead
|
|
346
|
+
// of letting it derive scope from the lane's self-reported filesChanged. A lane that mislabels a
|
|
347
|
+
// real code file as .md / under a docs/ segment / omits the manifest therefore cannot reach the
|
|
348
|
+
// prose-only accept path. The integration lane's `paths` are computed by the controller via
|
|
349
|
+
// changedPathsSinceBase at commit time (see runChildAgent integration branch).
|
|
350
|
+
let controllerChangedPaths;
|
|
351
|
+
if (laneReport?.itemId) {
|
|
352
|
+
const lookupCallId = drainLaneCallId(undefined, { ...context, itemId: laneReport.itemId });
|
|
353
|
+
const integrationLane = findIntegrationLane(run, lookupCallId);
|
|
354
|
+
if (Array.isArray(integrationLane?.paths)) controllerChangedPaths = integrationLane.paths;
|
|
355
|
+
}
|
|
356
|
+
const validationReport = await rawAdapter.validate(item, integrationState, { ...context, controllerChangedPaths });
|
|
357
|
+
if (laneReport?.itemId) {
|
|
358
|
+
const callId = drainLaneCallId(undefined, { ...context, itemId: laneReport.itemId });
|
|
359
|
+
const accepted = validationReport.accepted === true
|
|
360
|
+
&& validationReport.diffScopeOk === true
|
|
361
|
+
&& validationReport.followupsHandled === true
|
|
362
|
+
&& drainLaneReadyForIntegration(laneReport);
|
|
363
|
+
await markDrainLaneIntegration(run, callId, laneReport, accepted, accepted ? "accepted by drain adapter validation" : validationReport.reason || "drain adapter validation rejected lane");
|
|
364
|
+
}
|
|
365
|
+
return validationReport;
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
const report = await runDrainRuntime({
|
|
369
|
+
...runtimeOptions,
|
|
370
|
+
adapter,
|
|
371
|
+
gateStatus,
|
|
372
|
+
checkLifecycle: () => {
|
|
373
|
+
throwIfAborted(run, toolContext);
|
|
374
|
+
},
|
|
375
|
+
canLaunchLane: () => {
|
|
376
|
+
checkBudgetBeforeLaunch(run);
|
|
377
|
+
throwIfAborted(run, toolContext);
|
|
378
|
+
return run.agentsStarted < run.maxAgents;
|
|
379
|
+
},
|
|
380
|
+
runLane: async (packet, attemptContext) => {
|
|
381
|
+
const callId = drainLaneCallId(packet, attemptContext);
|
|
382
|
+
const childResult = await runChildAgent(pluginContext, toolContext, run, {
|
|
383
|
+
callId,
|
|
384
|
+
prompt: drainLanePrompt(packet, attemptContext),
|
|
385
|
+
opts: {
|
|
386
|
+
worktreeEdit: true,
|
|
387
|
+
timeoutMs: drainLaneTimeoutMs,
|
|
388
|
+
schema: DRAIN_LANE_REPORT_SCHEMA,
|
|
389
|
+
onFailure: "returnNull",
|
|
390
|
+
label: `${adapterName} drain ${drainLaneItemId(packet, attemptContext) || "item"}`,
|
|
391
|
+
// Keep implementation-lane behavior stable when the drain is orchestrated from a
|
|
392
|
+
// coordinating primary agent such as proxy.
|
|
393
|
+
agent: "build",
|
|
394
|
+
},
|
|
395
|
+
}, deps);
|
|
396
|
+
const failedRecord = run.laneRecords?.get(callId);
|
|
397
|
+
const laneReport = childResult ?? failedDrainLaneReport(packet, attemptContext, failedRecord?.errorSummary || "lane failed or returned invalid structured output", failedRecord?.salvage);
|
|
398
|
+
if (laneReport.salvage?.dirty) {
|
|
399
|
+
await appendValidationLedger(run, {
|
|
400
|
+
phase: "salvage-validation-skipped",
|
|
401
|
+
callId,
|
|
402
|
+
itemId: laneReport.itemId,
|
|
403
|
+
reason: "dirty timed-out lane did not return a structured report; controller preserved salvage evidence but did not validate or apply it",
|
|
404
|
+
worktreePath: laneReport.salvage.worktreePath,
|
|
405
|
+
changedFiles: laneReport.salvage.changedFiles,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
await markDrainLaneIntegration(run, callId, laneReport, false, drainLaneReadyForIntegration(laneReport) ? "pending drain adapter validation" : laneReport.summary || "lane not ready for integration");
|
|
409
|
+
return laneReport;
|
|
410
|
+
},
|
|
411
|
+
integrate: async (laneReports, attemptContext) => {
|
|
412
|
+
const committedLanes = [];
|
|
413
|
+
const missing = [];
|
|
414
|
+
for (const laneReport of laneReports) {
|
|
415
|
+
const callId = drainLaneCallId(undefined, { ...attemptContext, itemId: laneReport.itemId });
|
|
416
|
+
const lane = findIntegrationLane(run, callId);
|
|
417
|
+
// Defense-in-depth for the read-only-vs-edit asymmetry: a salvaged lane (result recovered
|
|
418
|
+
// from a transcript) has no worktree commit by construction and can never integrate, even
|
|
419
|
+
// if malformed state carried both committed and salvagedFromTranscript. The distinct
|
|
420
|
+
// reason keeps salvage provenance out of the merge path for the right cause.
|
|
421
|
+
const rejectReason = !drainLaneReadyForIntegration(laneReport)
|
|
422
|
+
? "lane-report-not-accepted"
|
|
423
|
+
: lane?.salvagedFromTranscript === true
|
|
424
|
+
? "lane-salvaged-from-transcript"
|
|
425
|
+
: lane?.committed !== true
|
|
426
|
+
? "lane-not-committed"
|
|
427
|
+
: undefined;
|
|
428
|
+
if (rejectReason) {
|
|
429
|
+
missing.push({ itemId: laneReport.itemId, callId, reason: rejectReason });
|
|
430
|
+
if (lane) await markDrainLaneIntegration(run, callId, laneReport, false, rejectReason);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
committedLanes.push(lane);
|
|
434
|
+
}
|
|
435
|
+
if (missing.length > 0) return { status: "review-required", reason: "lane-not-accepted-for-integration", laneReports, committedLanes: committedLanes.filter(Boolean), missing };
|
|
436
|
+
return { status: "integrated", laneReports, committedLanes: committedLanes.filter(Boolean) };
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
await appendEvent(run, { type: "drain.completed", adapter: adapterName, status: report.status });
|
|
440
|
+
return report;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// --- QuickJS sandbox lifecycle ---
|
|
444
|
+
|
|
445
|
+
function readVmErrorMessage(vm, errorHandle) {
|
|
446
|
+
// dump() of a QuickJS Error drops its non-enumerable .message; prefer reading it
|
|
447
|
+
// directly from the handle. Anything reaching here already escaped the body try/catch,
|
|
448
|
+
// so this only covers prelude/wrapper-construction or other VM-internal rejections.
|
|
449
|
+
try {
|
|
450
|
+
const msgHandle = errorHandle.getProp("message");
|
|
451
|
+
const msg = vm.dump(msgHandle);
|
|
452
|
+
msgHandle.dispose();
|
|
453
|
+
if (typeof msg === "string" && msg.length > 0) return msg;
|
|
454
|
+
} catch {}
|
|
455
|
+
const dumped = vm.dump(errorHandle);
|
|
456
|
+
if (typeof dumped === "string" && dumped.length > 0) return dumped;
|
|
457
|
+
if (dumped && typeof dumped === "object" && typeof dumped.message === "string" && dumped.message.length > 0) return dumped.message;
|
|
458
|
+
try { return JSON.stringify(dumped); } catch { return "[object Object]"; }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Host-owned artifact persistence (iui1.4). A read-only workflow guest cannot write files, and the
|
|
462
|
+
// workflow return value is capped at MAX_RESULT_BYTES, so a large finding set can only survive if it
|
|
463
|
+
// is spilled to disk DURING execution. This host op lets the guest pass LOGICAL filenames only
|
|
464
|
+
// (content as a string or JSON-able object/array); the host roots them under the controller-owned,
|
|
465
|
+
// gitignored run store (run.dir/artifacts/<namespace>/) and writes each atomically (temp + rename).
|
|
466
|
+
//
|
|
467
|
+
// It returns {ok, dir, files} on success and {ok:false, error, dir, files} on a write/validation
|
|
468
|
+
// failure — it does NOT throw — so a read-only review can complete with materializationReady:false
|
|
469
|
+
// (blocker: artifactPersistenceFailed) instead of crashing the whole workflow. This preserves the
|
|
470
|
+
// read-only-review contract: the workflow performs no workspace/source writes, only controller-owned
|
|
471
|
+
// runtime artifacts under the run directory (the kernel already persists state/events/result there).
|
|
472
|
+
const ARTIFACT_MAX_BYTES = 16 * 1024 * 1024; // generous — the bead exists because output can exceed 256 KiB
|
|
473
|
+
async function persistRunArtifacts(pluginContext, run, payload) {
|
|
474
|
+
if (pluginContext?.__workflowArtifactFail) {
|
|
475
|
+
return { ok: false, error: "artifact persistence disabled by test hook", dir: null, files: [] };
|
|
476
|
+
}
|
|
477
|
+
const namespace = safeProjectionName(String(payload?.namespace ?? "workflow")) || "workflow";
|
|
478
|
+
const files = Array.isArray(payload?.files) ? payload.files : Array.isArray(payload) ? payload : [];
|
|
479
|
+
if (!files.length) return { ok: false, error: "persistArtifacts requires a non-empty files array", dir: null, files: [] };
|
|
480
|
+
const root = path.join(run.dir, "artifacts", namespace);
|
|
481
|
+
try {
|
|
482
|
+
await ensurePrivateDir(root);
|
|
483
|
+
} catch (error) {
|
|
484
|
+
return { ok: false, error: `artifact root mkdir failed: ${extractTextFromError(error)}`, dir: null, files: [] };
|
|
485
|
+
}
|
|
486
|
+
const out = [];
|
|
487
|
+
for (const f of files) {
|
|
488
|
+
if (!f || typeof f !== "object") continue;
|
|
489
|
+
const rawName = String(f.name ?? f.filename ?? "").trim();
|
|
490
|
+
if (!rawName) return { ok: false, error: "artifact missing name", dir: root, files: out };
|
|
491
|
+
// Sanitize: basename only, safe charset, not dot-prefixed (blocks ".." traversal), restricted
|
|
492
|
+
// extensions. The charset already forbids "/" and "\" so no path traversal is possible.
|
|
493
|
+
const base = path.basename(rawName);
|
|
494
|
+
if (!/^[A-Za-z0-9._-]+$/.test(base) || base.startsWith(".")) {
|
|
495
|
+
return { ok: false, error: `artifact name rejected: ${rawName}`, dir: root, files: out };
|
|
496
|
+
}
|
|
497
|
+
if (!/\.(json|jsonl|md)$/i.test(base)) {
|
|
498
|
+
return { ok: false, error: `artifact name must end in .json/.jsonl/.md: ${rawName}`, dir: root, files: out };
|
|
499
|
+
}
|
|
500
|
+
let content = f.content;
|
|
501
|
+
if (content === null || content === undefined) content = "";
|
|
502
|
+
if (typeof content === "object") content = stableStringify(content);
|
|
503
|
+
const str = String(content);
|
|
504
|
+
const bytes = Buffer.byteLength(str, "utf8");
|
|
505
|
+
if (bytes > ARTIFACT_MAX_BYTES) {
|
|
506
|
+
return { ok: false, error: `artifact ${base} exceeds ${ARTIFACT_MAX_BYTES} bytes`, dir: root, files: out };
|
|
507
|
+
}
|
|
508
|
+
const dest = path.join(root, base);
|
|
509
|
+
const tmp = path.join(root, `.${base}.${randomBytes(6).toString("hex")}.tmp`);
|
|
510
|
+
try {
|
|
511
|
+
await writeFilePrivate(tmp, str, "utf8");
|
|
512
|
+
await fs.rename(tmp, dest);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
// A write/rename failure (EACCES/ENOSPC) can otherwise orphan a tmp file on disk.
|
|
515
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
516
|
+
return { ok: false, error: `artifact write failed for ${base}: ${extractTextFromError(error)}`, dir: root, files: out };
|
|
517
|
+
}
|
|
518
|
+
out.push({ name: base, path: dest, bytes });
|
|
519
|
+
}
|
|
520
|
+
await appendEvent(run, { type: "artifacts.persisted", namespace, count: out.length });
|
|
521
|
+
return { ok: true, dir: root, files: out };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Deterministic file inventory + sharding (iui1.5). Coverage was previously agent-discovered
|
|
525
|
+
// ("Explore with your tools"); on very large repos agents sample, overfocus, or hit context
|
|
526
|
+
// limits. This host op does a deterministic, sorted fs walk of the project root, applies the
|
|
527
|
+
// paths/exclude scope, classifies files by language/role, and partitions the manifest into
|
|
528
|
+
// bounded shards (by source root, capped at shardSize files each). The manifest grounds recon
|
|
529
|
+
// and the leaf prompts in the REAL file set, and a shard ledger tracks coverage so a missed/
|
|
530
|
+
// failed shard blocks materialization. Bounded (INVENTORY_MAX_FILES) and cycle-safe (symlinks
|
|
531
|
+
// + visited-set skipped); returns {ok, manifest, shards, partial} or {ok:false, error}.
|
|
532
|
+
const INVENTORY_ALWAYS_EXCLUDE = new Set([".git", ".opencode", ".beads", ".repo-review", "node_modules", "dist", "build", "target", "vendor"]);
|
|
533
|
+
const INVENTORY_MAX_FILES = 200000;
|
|
534
|
+
const INVENTORY_SHARD_DEFAULT = 2000;
|
|
535
|
+
const INVENTORY_LANG_BY_EXT = {
|
|
536
|
+
".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".jsx": "javascript",
|
|
537
|
+
".ts": "typescript", ".tsx": "typescript", ".py": "python", ".rb": "ruby", ".go": "go",
|
|
538
|
+
".rs": "rust", ".java": "java", ".kt": "kotlin", ".php": "php", ".cs": "csharp",
|
|
539
|
+
".c": "c", ".h": "c", ".cpp": "cpp", ".cc": "cpp", ".swift": "swift", ".scala": "scala",
|
|
540
|
+
".sh": "shell", ".bash": "shell", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
|
|
541
|
+
".toml": "toml", ".xml": "xml", ".md": "markdown", ".css": "css", ".scss": "css", ".html": "html",
|
|
542
|
+
};
|
|
543
|
+
function __globToRegex(pattern) {
|
|
544
|
+
let re = "";
|
|
545
|
+
for (const ch of String(pattern)) {
|
|
546
|
+
if (ch === "*") re += "[^/]*";
|
|
547
|
+
else if (ch === "?") re += "[^/]";
|
|
548
|
+
else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
549
|
+
}
|
|
550
|
+
return new RegExp(`^${re}$`);
|
|
551
|
+
}
|
|
552
|
+
function __compileExcludeGlobs(excludes) {
|
|
553
|
+
const literals = new Set();
|
|
554
|
+
const globs = [];
|
|
555
|
+
for (const raw of excludes) {
|
|
556
|
+
const ex = String(raw);
|
|
557
|
+
if (!ex.includes("*") && !ex.includes("?")) literals.add(ex);
|
|
558
|
+
else globs.push({ source: ex, regex: __globToRegex(ex) });
|
|
559
|
+
}
|
|
560
|
+
return { literals, globs };
|
|
561
|
+
}
|
|
562
|
+
function __isPathExcluded(segments, basename, excludes) {
|
|
563
|
+
for (const ex of excludes.literals) {
|
|
564
|
+
if (segments.includes(ex) || basename === ex) return true;
|
|
565
|
+
}
|
|
566
|
+
for (const ex of excludes.globs) {
|
|
567
|
+
if (ex.regex.test(basename)) return true;
|
|
568
|
+
}
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
function __langOf(basename) {
|
|
572
|
+
const ext = path.extname(basename).toLowerCase();
|
|
573
|
+
return INVENTORY_LANG_BY_EXT[ext] || (ext ? ext.slice(1) : "other");
|
|
574
|
+
}
|
|
575
|
+
function __roleOf(rel, basename) {
|
|
576
|
+
const b = (rel + "/" + basename).toLowerCase();
|
|
577
|
+
if (/(^|\/)tests?\/|\.test\.|\.spec\.|_test\.|\/test\//.test(b)) return "test";
|
|
578
|
+
if (/\.lock$|lock\.(json|yaml)$/.test(b)) return "lockfile";
|
|
579
|
+
if (/(^|\/)(package\.json|requirements\.txt|pyproject\.toml|go\.mod|cargo\.toml|gemfile|pom\.xml|composer\.json)$/i.test(b)) return "manifest";
|
|
580
|
+
if (/(^|\/)(dockerfile|docker-compose|containerfile)/i.test(b) || /\.(dockerfile|containerfile)$/i.test(b)) return "container";
|
|
581
|
+
if (/(^|\/)\.github\/workflows\//.test(b) || /\.gitlab-ci\.|\.circleci/.test(b)) return "ci";
|
|
582
|
+
return "source";
|
|
583
|
+
}
|
|
584
|
+
async function inventoryRunFiles(pluginContext, toolContext, run, payload) {
|
|
585
|
+
if (pluginContext?.__workflowInventoryFail) {
|
|
586
|
+
return { ok: false, error: "inventory disabled by test hook", manifest: null, shards: [], partial: false };
|
|
587
|
+
}
|
|
588
|
+
if (typeof pluginContext?.__workflowInventory === "function") {
|
|
589
|
+
try { return await pluginContext.__workflowInventory(payload); }
|
|
590
|
+
catch (error) { return { ok: false, error: extractTextFromError(error), manifest: null, shards: [], partial: false }; }
|
|
591
|
+
}
|
|
592
|
+
const projectRoot = path.resolve(toolContext?.worktree || toolContext?.directory || ".");
|
|
593
|
+
const paths = Array.isArray(payload?.paths) && payload.paths.length ? payload.paths : ["."];
|
|
594
|
+
const excludeGlobs = Array.isArray(payload?.exclude) ? payload.exclude : [];
|
|
595
|
+
const excludeRules = __compileExcludeGlobs(excludeGlobs);
|
|
596
|
+
const shardSize = Number.isInteger(payload?.shardSize) && payload.shardSize > 0 ? payload.shardSize : INVENTORY_SHARD_DEFAULT;
|
|
597
|
+
try {
|
|
598
|
+
const files = [];
|
|
599
|
+
let partial = false;
|
|
600
|
+
const visited = new Set();
|
|
601
|
+
async function walk(dir, depth) {
|
|
602
|
+
if (depth > 32) return;
|
|
603
|
+
let entries;
|
|
604
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); }
|
|
605
|
+
catch { return; }
|
|
606
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
607
|
+
for (const ent of entries) {
|
|
608
|
+
if (ent.isSymbolicLink()) continue;
|
|
609
|
+
if (INVENTORY_ALWAYS_EXCLUDE.has(ent.name)) continue;
|
|
610
|
+
const rel = path.relative(projectRoot, path.join(dir, ent.name)).split(path.sep).join("/");
|
|
611
|
+
const segs = rel.split("/");
|
|
612
|
+
if (__isPathExcluded(segs, ent.name, excludeRules)) continue;
|
|
613
|
+
if (ent.isDirectory()) {
|
|
614
|
+
const key = path.resolve(dir, ent.name);
|
|
615
|
+
if (visited.has(key)) continue;
|
|
616
|
+
visited.add(key);
|
|
617
|
+
await walk(key, depth + 1);
|
|
618
|
+
} else if (ent.isFile()) {
|
|
619
|
+
if (files.length >= INVENTORY_MAX_FILES) { partial = true; return; }
|
|
620
|
+
files.push({ path: rel, lang: __langOf(ent.name), role: __roleOf(rel, ent.name) });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
for (const p of paths) {
|
|
625
|
+
const start = path.resolve(projectRoot, String(p));
|
|
626
|
+
if (!pathContains(projectRoot, start)) {
|
|
627
|
+
partial = true;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
const st = await fs.stat(start);
|
|
632
|
+
if (st.isDirectory()) {
|
|
633
|
+
if (!visited.has(start)) { visited.add(start); await walk(start, 0); }
|
|
634
|
+
} else if (st.isFile()) {
|
|
635
|
+
if (files.length < INVENTORY_MAX_FILES) {
|
|
636
|
+
const rel = path.relative(projectRoot, start).split(path.sep).join("/");
|
|
637
|
+
files.push({ path: rel, lang: __langOf(path.basename(start)), role: __roleOf(rel, path.basename(start)) });
|
|
638
|
+
} else { partial = true; }
|
|
639
|
+
}
|
|
640
|
+
} catch { /* missing path — skip */ }
|
|
641
|
+
}
|
|
642
|
+
const byLanguage = {}, byRole = {};
|
|
643
|
+
const byRoot = {};
|
|
644
|
+
for (const f of files) {
|
|
645
|
+
byLanguage[f.lang] = (byLanguage[f.lang] || 0) + 1;
|
|
646
|
+
byRole[f.role] = (byRole[f.role] || 0) + 1;
|
|
647
|
+
const root = f.path.split("/")[0] || "<root>";
|
|
648
|
+
(byRoot[root] = byRoot[root] || []).push(f);
|
|
649
|
+
}
|
|
650
|
+
const shards = [];
|
|
651
|
+
const rootNames = Object.keys(byRoot).sort();
|
|
652
|
+
for (const root of rootNames) {
|
|
653
|
+
const group = byRoot[root];
|
|
654
|
+
for (let i = 0; i < group.length; i += shardSize) {
|
|
655
|
+
const slice = group.slice(i, i + shardSize);
|
|
656
|
+
shards.push({
|
|
657
|
+
id: `${root}-${Math.floor(i / shardSize) + 1}`,
|
|
658
|
+
root, fileCount: slice.length,
|
|
659
|
+
languages: [...new Set(slice.map((f) => f.lang))],
|
|
660
|
+
paths: slice.map((f) => f.path),
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const manifest = { totalFiles: files.length, byLanguage, byRole, sourceRoots: rootNames, paths, exclude: excludeGlobs, partial };
|
|
665
|
+
await appendEvent(run, { type: "inventory.completed", files: files.length, shards: shards.length, partial });
|
|
666
|
+
return { ok: true, manifest, shards, partial };
|
|
667
|
+
} catch (error) {
|
|
668
|
+
return { ok: false, error: `inventory failed: ${extractTextFromError(error)}`, manifest: null, shards: [], partial: false };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Execute a workflow body inside a deterministic QuickJS sandbox, bridging guest host-ops
|
|
674
|
+
* (agent/log/phase/budget/workflow/drain/fanout helpers/persistArtifacts) to the trusted controller.
|
|
675
|
+
*
|
|
676
|
+
* @param {object} pluginContext OpenCode plugin context.
|
|
677
|
+
* @param {object} toolContext Tool-call context.
|
|
678
|
+
* @param {RunContext} run Shared mutable run state.
|
|
679
|
+
* @param {string} body Workflow body source (executed inside the VM).
|
|
680
|
+
* @param {*} args Runtime args injected as globalThis.args.
|
|
681
|
+
* @param {object} deps Orchestrator-resident primitives forwarded to runChildAgent and host dispatch.
|
|
682
|
+
* @returns {Promise<*>} The workflow body's return value.
|
|
683
|
+
*/
|
|
684
|
+
async function executeSandbox(pluginContext, toolContext, run, body, args, deps) {
|
|
685
|
+
const throwIfAborted = typeof deps?.throwIfAborted === "function" ? deps.throwIfAborted : () => {};
|
|
686
|
+
const vm = await newSandboxContext();
|
|
687
|
+
const runtime = vm.runtime;
|
|
688
|
+
const guestDeadlineMs = run.guestDeadlineMs || DEFAULT_GUEST_DEADLINE_MS;
|
|
689
|
+
// The interrupt deadline guards against runaway *synchronous* guest code; it is NOT a
|
|
690
|
+
// workflow wall-clock budget. It must be re-armed before every guest-execution burst:
|
|
691
|
+
// the guest is Asyncify-suspended for the entire duration of each host await (a child
|
|
692
|
+
// agent can run for minutes), so an absolute deadline armed once at sandbox entry would
|
|
693
|
+
// fire the instant the guest resumes after the first slow agent and kill the whole run.
|
|
694
|
+
const armDeadline = () => vm.runtime?.setInterruptHandler?.(shouldInterruptAfterDeadline(Date.now() + guestDeadlineMs));
|
|
695
|
+
armDeadline();
|
|
696
|
+
vm.runtime?.setMemoryLimit?.(32 * 1024 * 1024);
|
|
697
|
+
const deferreds = [];
|
|
698
|
+
const handles = [];
|
|
699
|
+
const pendingHostOps = new Map();
|
|
700
|
+
|
|
701
|
+
function executePendingJobs() {
|
|
702
|
+
for (let i = 0; i < MAX_PENDING_JOB_DRAIN_ITERATIONS; i += 1) {
|
|
703
|
+
const result = runtime.executePendingJobs();
|
|
704
|
+
try {
|
|
705
|
+
if (result?.error) throw vm.unwrapResult(result);
|
|
706
|
+
if (!Number.isFinite(result?.value) || result.value <= 0) return;
|
|
707
|
+
} finally {
|
|
708
|
+
result?.dispose?.();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
throw new Error(`QuickJS pending job drain exceeded ${MAX_PENDING_JOB_DRAIN_ITERATIONS} iterations`);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function settlePendingHostOps() {
|
|
715
|
+
if (pendingHostOps.size === 0) return;
|
|
716
|
+
for (const lane of run.activeLaneAbortControllers?.values?.() ?? []) {
|
|
717
|
+
try { lane.abortController?.abort?.(); } catch {}
|
|
718
|
+
if (lane.childID) {
|
|
719
|
+
try {
|
|
720
|
+
if (lane.childAbortRequested !== true) {
|
|
721
|
+
lane.childAbortRequested = true;
|
|
722
|
+
await abortChild(pluginContext, lane.childID, lane.directory ?? toolContext.directory);
|
|
723
|
+
}
|
|
724
|
+
} catch {}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
try { rejectWaitingAgents(run, new WorkflowCancelledError("Workflow host operation was not awaited")); } catch {}
|
|
728
|
+
const settleTimeoutMs = Number.isFinite(pluginContext?.__workflowPendingHostOpSettleTimeoutMs) && pluginContext.__workflowPendingHostOpSettleTimeoutMs > 0
|
|
729
|
+
? pluginContext.__workflowPendingHostOpSettleTimeoutMs
|
|
730
|
+
: PENDING_HOST_OP_SETTLE_TIMEOUT_MS;
|
|
731
|
+
await Promise.race([
|
|
732
|
+
Promise.allSettled([...pendingHostOps.values()]),
|
|
733
|
+
sleep(settleTimeoutMs),
|
|
734
|
+
]);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function rejectFloatingHostOpsIfAny() {
|
|
738
|
+
if (pendingHostOps.size === 0) return;
|
|
739
|
+
const count = pendingHostOps.size;
|
|
740
|
+
await settlePendingHostOps();
|
|
741
|
+
throw new Error(`Workflow host operation${count === 1 ? "" : "s"} must be awaited before returning (${count} pending)`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function hostReturn(value) {
|
|
745
|
+
return vm.newString(JSON.stringify(value ?? null));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const host = vm.newFunction("__workflowHost", (opHandle, payloadHandle) => {
|
|
749
|
+
const op = vm.getString(opHandle);
|
|
750
|
+
const payload = vm.dump(payloadHandle);
|
|
751
|
+
const deferred = vm.newPromise();
|
|
752
|
+
deferreds.push(deferred);
|
|
753
|
+
|
|
754
|
+
const pendingToken = {};
|
|
755
|
+
let hostOpRejected = false;
|
|
756
|
+
const hostOp = (async () => {
|
|
757
|
+
try {
|
|
758
|
+
throwIfAborted(run, toolContext);
|
|
759
|
+
run.hostCalls += 1;
|
|
760
|
+
const maxHostCalls = maxHostCallsForRun(run);
|
|
761
|
+
if (run.hostCalls > maxHostCalls) throw new Error(`Workflow exceeded max host calls (${maxHostCalls})`);
|
|
762
|
+
await sandboxHostOpTestHook?.({ op, payload, run });
|
|
763
|
+
let value;
|
|
764
|
+
if (op === "agent") value = await runChildAgent(pluginContext, toolContext, run, payload, deps);
|
|
765
|
+
else if (op === "noop") value = null;
|
|
766
|
+
else if (op === "log") {
|
|
767
|
+
const message = truncateText(String(payload.message ?? ""), MAX_EVENT_MESSAGE_CHARS);
|
|
768
|
+
recordRecentLog(run, message);
|
|
769
|
+
await appendEvent(run, { type: "log", message });
|
|
770
|
+
value = null;
|
|
771
|
+
} else if (op === "phase") {
|
|
772
|
+
run.currentPhase = String(payload.name ?? "");
|
|
773
|
+
await appendEvent(run, { type: "phase", phase: run.currentPhase });
|
|
774
|
+
await writeState(run);
|
|
775
|
+
await maybeShowWorkflowProgressToast(pluginContext, run);
|
|
776
|
+
value = null;
|
|
777
|
+
} else if (op === "budget") {
|
|
778
|
+
value = budgetSnapshot(run);
|
|
779
|
+
} else if (op === "workflow") {
|
|
780
|
+
value = await runNestedWorkflow(pluginContext, toolContext, run, payload, deps);
|
|
781
|
+
} else if (op === "drain") {
|
|
782
|
+
value = await runHostDrain(pluginContext, toolContext, run, payload, deps);
|
|
783
|
+
} else if (op === "persistArtifacts") {
|
|
784
|
+
value = await persistRunArtifacts(pluginContext, run, payload);
|
|
785
|
+
} else if (op === "inventoryFiles") {
|
|
786
|
+
value = await inventoryRunFiles(pluginContext, toolContext, run, payload);
|
|
787
|
+
} else if (op === "fanoutFailure") {
|
|
788
|
+
run.droppedLaneCount += 1;
|
|
789
|
+
recordFanoutDroppedDiagnostic(run, payload);
|
|
790
|
+
await appendEvent(run, { type: "fanout.lane_dropped", scope: payload.scope, error: truncateText(payload.error ?? "", MAX_STATUS_STRING_CHARS) });
|
|
791
|
+
await writeState(run);
|
|
792
|
+
value = null;
|
|
793
|
+
} else if (op === "fanoutCancel") {
|
|
794
|
+
await cancelFanoutSiblings(pluginContext, run, String(payload.scope ?? ""), String(payload.reason ?? "failFast sibling cancellation"));
|
|
795
|
+
value = null;
|
|
796
|
+
} else if (op === "sequentialFanout") {
|
|
797
|
+
await appendEvent(run, { type: "fanout.sequential", scope: payload.scope, helper: payload.helper });
|
|
798
|
+
value = null;
|
|
799
|
+
} else {
|
|
800
|
+
throw new Error(`Unsupported workflow host operation: ${op}`);
|
|
801
|
+
}
|
|
802
|
+
throwIfAborted(run, toolContext);
|
|
803
|
+
const handle = hostReturn(value);
|
|
804
|
+
deferred.resolve(handle);
|
|
805
|
+
handle.dispose();
|
|
806
|
+
} catch (error) {
|
|
807
|
+
hostOpRejected = true;
|
|
808
|
+
const handle = vm.newError(extractTextFromError(error));
|
|
809
|
+
deferred.reject(handle);
|
|
810
|
+
handle.dispose();
|
|
811
|
+
} finally {
|
|
812
|
+
pendingHostOps.delete(pendingToken);
|
|
813
|
+
armDeadline();
|
|
814
|
+
executePendingJobs();
|
|
815
|
+
// Free the settled deferred's Promise handle as soon as the guest can no
|
|
816
|
+
// longer need it, instead of letting every settled host op stay rooted in
|
|
817
|
+
// the 32MB-capped QuickJS heap until the whole run ends (a host-call-heavy
|
|
818
|
+
// loop would otherwise accumulate thousands of dead handles). The guest is
|
|
819
|
+
// asyncify-suspended at its `await` when this finally runs, so only the
|
|
820
|
+
// RESOLVE path is safe to drop here: quickjs-emscripten has already
|
|
821
|
+
// delivered the resolved value into the asyncify resume buffer, making our
|
|
822
|
+
// Promise handle dead weight. A REJECTED deferred must survive until the
|
|
823
|
+
// guest resumes and reads the rejection reason from the handle — disposing
|
|
824
|
+
// it early corrupts that read into "Lifetime not alive" — so leave rejected
|
|
825
|
+
// handles for the outer finally-loop (an uncaught rejection is terminal and
|
|
826
|
+
// does not accumulate). Removing disposed handles from `deferreds` keeps the
|
|
827
|
+
// outer loop from double-freeing and leaves only genuinely-live handles.
|
|
828
|
+
if (!hostOpRejected) {
|
|
829
|
+
try { deferred.dispose(); } catch {}
|
|
830
|
+
const deferredIndex = deferreds.indexOf(deferred);
|
|
831
|
+
if (deferredIndex !== -1) deferreds.splice(deferredIndex, 1);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
})();
|
|
835
|
+
pendingHostOps.set(pendingToken, hostOp);
|
|
836
|
+
hostOp.catch(() => {
|
|
837
|
+
pendingHostOps.delete(pendingToken);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
return deferred.handle;
|
|
841
|
+
});
|
|
842
|
+
handles.push(host);
|
|
843
|
+
vm.setProp(vm.global, "__workflowHost", host);
|
|
844
|
+
|
|
845
|
+
const prelude = `
|
|
846
|
+
globalThis.Date = function Date() { throw new Error("Date is disabled in deterministic workflows"); };
|
|
847
|
+
globalThis.Date.now = function () { throw new Error("Date.now is disabled in deterministic workflows"); };
|
|
848
|
+
globalThis.Math.random = function () { throw new Error("Math.random is disabled in deterministic workflows"); };
|
|
849
|
+
globalThis.performance = undefined;
|
|
850
|
+
globalThis.crypto = undefined;
|
|
851
|
+
globalThis.setTimeout = undefined;
|
|
852
|
+
globalThis.setInterval = undefined;
|
|
853
|
+
globalThis.clearTimeout = undefined;
|
|
854
|
+
globalThis.clearInterval = undefined;
|
|
855
|
+
const __workflowArgs = ${JSON.stringify(args ?? null)};
|
|
856
|
+
function __makeScope(path) { return { path, next: 0 }; }
|
|
857
|
+
function __nextIn(scope, kind) { return scope.path + "/" + kind + ":" + (scope.next++); }
|
|
858
|
+
async function __host(op, payload) { return JSON.parse(await globalThis.__workflowHost(op, payload)); }
|
|
859
|
+
|
|
860
|
+
const __rootScope = __makeScope("root");
|
|
861
|
+
let __activeScope = __rootScope;
|
|
862
|
+
async function __withActiveScope(scope, fn) {
|
|
863
|
+
const previous = __activeScope;
|
|
864
|
+
__activeScope = scope;
|
|
865
|
+
try { return await fn(); }
|
|
866
|
+
finally { __activeScope = previous; }
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const __sharedApi = {
|
|
870
|
+
phase: async (name) => await __host("phase", { name }),
|
|
871
|
+
log: async (message) => await __host("log", { message }),
|
|
872
|
+
budget: {
|
|
873
|
+
spent: async () => (await __host("budget", {})).total.tokens,
|
|
874
|
+
live: async () => (await __host("budget", {})).live,
|
|
875
|
+
replayed: async () => (await __host("budget", {})).replayed,
|
|
876
|
+
cost: async () => (await __host("budget", {})).total.cost,
|
|
877
|
+
ceilings: async () => (await __host("budget", {})).ceilings,
|
|
878
|
+
remaining: async () => (await __host("budget", {})).remaining,
|
|
879
|
+
remainingAgents: async () => (await __host("budget", {})).remainingAgents,
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
function __makeApi(scope) {
|
|
884
|
+
const api = {
|
|
885
|
+
agent: async (prompt, opts = {}) => await __host("agent", { callId: __nextIn(scope, "agent"), prompt, opts }),
|
|
886
|
+
phase: __sharedApi.phase,
|
|
887
|
+
log: __sharedApi.log,
|
|
888
|
+
budget: __sharedApi.budget,
|
|
889
|
+
parallel: async (thunks, options = {}) => await __parallel(scope, thunks, options),
|
|
890
|
+
pipeline: async (items, ...stagesAndOptions) => await __pipeline(scope, items, ...stagesAndOptions),
|
|
891
|
+
};
|
|
892
|
+
return Object.freeze(api);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
globalThis.args = __workflowArgs;
|
|
896
|
+
globalThis.agent = async function agent(prompt, opts = {}) {
|
|
897
|
+
return await __host("agent", { callId: __nextIn(__activeScope, "agent"), prompt, opts });
|
|
898
|
+
};
|
|
899
|
+
globalThis.phase = __sharedApi.phase;
|
|
900
|
+
globalThis.log = __sharedApi.log;
|
|
901
|
+
globalThis.budget = __sharedApi.budget;
|
|
902
|
+
// iui1.4: host-owned artifact spill so a large finding set survives the MAX_RESULT_BYTES return
|
|
903
|
+
// cap. The guest passes logical filenames only; the host roots them under run.dir/artifacts/.
|
|
904
|
+
globalThis.persistArtifacts = async function persistArtifacts(payload) {
|
|
905
|
+
return await __host("persistArtifacts", payload);
|
|
906
|
+
};
|
|
907
|
+
// iui1.5: deterministic file inventory + sharding. The guest passes scope (paths/exclude) and
|
|
908
|
+
// shardSize; the host walks the project root and returns a structured manifest + shards.
|
|
909
|
+
globalThis.inventoryFiles = async function inventoryFiles(payload) {
|
|
910
|
+
return await __host("inventoryFiles", payload);
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
function __splitOptions(values) {
|
|
914
|
+
if (!values.length) return { values, options: {} };
|
|
915
|
+
const last = values[values.length - 1];
|
|
916
|
+
if (last && typeof last === "object" && typeof last !== "function" && !Array.isArray(last)) {
|
|
917
|
+
return { values: values.slice(0, -1), options: last };
|
|
918
|
+
}
|
|
919
|
+
return { values, options: {} };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function __isScopedCallback(fn) {
|
|
923
|
+
return typeof fn === "function" && fn.length > 0;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function __zeroParamCallbackIndexes(callbacks) {
|
|
927
|
+
const indexes = [];
|
|
928
|
+
for (let index = 0; index < callbacks.length; index += 1) {
|
|
929
|
+
if (!__isScopedCallback(callbacks[index])) indexes.push(index);
|
|
930
|
+
}
|
|
931
|
+
return indexes;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function __fanoutArityError(helper, indexes) {
|
|
935
|
+
return new Error(helper + "() requires every callback to declare a scope parameter for concurrent, resume-safe execution (for example, (api) => api.agent(...)). Callback(s) at index " + indexes.join(", ") + " declare 0 parameters. Default/rest parameters also have function.length === 0, so patterns like (api = {}) => ... or (...args) => ... do not count as scoped. Add a parameter and use the injected api/context (api.agent/api.parallel/api.pipeline), or pass { sequential: true } to intentionally run these item(s) one at a time.");
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async function __handleFanoutError(scope, error, options) {
|
|
939
|
+
if (options.failFast === true) throw error;
|
|
940
|
+
await __host("fanoutFailure", { scope, error: String(error && error.message ? error.message : error) });
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async function __failFastAll(group, tasks) {
|
|
945
|
+
let firstError;
|
|
946
|
+
let cancelling = false;
|
|
947
|
+
const settled = await Promise.allSettled(tasks.map((task) => (async () => {
|
|
948
|
+
try { return await task(); }
|
|
949
|
+
catch (error) {
|
|
950
|
+
if (!firstError) firstError = error;
|
|
951
|
+
if (!cancelling) {
|
|
952
|
+
cancelling = true;
|
|
953
|
+
await __host("fanoutCancel", { scope: group, reason: String(error && error.message ? error.message : error) });
|
|
954
|
+
}
|
|
955
|
+
throw error;
|
|
956
|
+
}
|
|
957
|
+
})()));
|
|
958
|
+
if (firstError) throw firstError;
|
|
959
|
+
return settled.map((entry) => entry.value);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async function __parallel(parentScope, thunks, options = {}) {
|
|
963
|
+
const group = __nextIn(parentScope, "parallel");
|
|
964
|
+
const sequential = options.sequential === true;
|
|
965
|
+
const scoped = !sequential && (options.scoped === true || thunks.every(__isScopedCallback));
|
|
966
|
+
if (scoped) {
|
|
967
|
+
const tasks = thunks.map((thunk, index) => async () => {
|
|
968
|
+
const scope = __makeScope(group + "/item:" + index);
|
|
969
|
+
try { return await thunk(__makeApi(scope), index); }
|
|
970
|
+
catch (error) { return await __handleFanoutError(scope.path, error, options); }
|
|
971
|
+
});
|
|
972
|
+
return options.failFast === true ? await __failFastAll(group, tasks) : await Promise.all(tasks.map((task) => task()));
|
|
973
|
+
}
|
|
974
|
+
if (!sequential) throw __fanoutArityError("parallel", __zeroParamCallbackIndexes(thunks));
|
|
975
|
+
|
|
976
|
+
await __host("sequentialFanout", { scope: group, helper: "parallel" });
|
|
977
|
+
const results = [];
|
|
978
|
+
for (let index = 0; index < thunks.length; index += 1) {
|
|
979
|
+
const scope = __makeScope(group + "/item:" + index);
|
|
980
|
+
try { results.push(await __withActiveScope(scope, () => thunks[index]())); }
|
|
981
|
+
catch (error) { results.push(await __handleFanoutError(scope.path, error, options)); }
|
|
982
|
+
}
|
|
983
|
+
return results;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function __pipeline(parentScope, items, ...stagesAndOptions) {
|
|
987
|
+
const split = __splitOptions(stagesAndOptions);
|
|
988
|
+
const stages = split.values;
|
|
989
|
+
const options = split.options;
|
|
990
|
+
const group = __nextIn(parentScope, "pipeline");
|
|
991
|
+
const sequential = options.sequential === true;
|
|
992
|
+
const scoped = !sequential && (options.scoped === true || stages.every(__isScopedCallback));
|
|
993
|
+
if (scoped) {
|
|
994
|
+
const tasks = items.map((item, itemIndex) => async () => {
|
|
995
|
+
let value = item;
|
|
996
|
+
for (let stageIndex = 0; stageIndex < stages.length; stageIndex += 1) {
|
|
997
|
+
const scope = __makeScope(group + "/item:" + itemIndex + "/stage:" + stageIndex);
|
|
998
|
+
const api = __makeApi(scope);
|
|
999
|
+
const context = Object.freeze({ ...api, previous: value, item, itemIndex, stageIndex });
|
|
1000
|
+
try { value = await stages[stageIndex](value, context, item, itemIndex, stageIndex); }
|
|
1001
|
+
catch (error) { return await __handleFanoutError(scope.path, error, options); }
|
|
1002
|
+
}
|
|
1003
|
+
return value;
|
|
1004
|
+
});
|
|
1005
|
+
return options.failFast === true ? await __failFastAll(group, tasks) : await Promise.all(tasks.map((task) => task()));
|
|
1006
|
+
}
|
|
1007
|
+
if (!sequential) throw __fanoutArityError("pipeline", __zeroParamCallbackIndexes(stages));
|
|
1008
|
+
|
|
1009
|
+
await __host("sequentialFanout", { scope: group, helper: "pipeline" });
|
|
1010
|
+
const results = [];
|
|
1011
|
+
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
|
|
1012
|
+
const item = items[itemIndex];
|
|
1013
|
+
let value = item;
|
|
1014
|
+
let dropped = false;
|
|
1015
|
+
for (let stageIndex = 0; stageIndex < stages.length; stageIndex += 1) {
|
|
1016
|
+
const scope = __makeScope(group + "/item:" + itemIndex + "/stage:" + stageIndex);
|
|
1017
|
+
try { value = await __withActiveScope(scope, () => stages[stageIndex](value, item, itemIndex, stageIndex)); }
|
|
1018
|
+
catch (error) { value = await __handleFanoutError(scope.path, error, options); dropped = true; }
|
|
1019
|
+
if (dropped) break;
|
|
1020
|
+
}
|
|
1021
|
+
results.push(value);
|
|
1022
|
+
}
|
|
1023
|
+
return results;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
globalThis.parallel = async function parallel(thunks, options = {}) { return await __parallel(__activeScope, thunks, options); };
|
|
1027
|
+
globalThis.pipeline = async function pipeline(items, ...stagesAndOptions) { return await __pipeline(__activeScope, items, ...stagesAndOptions); };
|
|
1028
|
+
globalThis.workflow = async function workflow(nameOrSource, nestedArgs = null) {
|
|
1029
|
+
let payload;
|
|
1030
|
+
if (typeof nameOrSource === "string") {
|
|
1031
|
+
payload = nameOrSource.includes("\\n") || nameOrSource.includes("export const meta")
|
|
1032
|
+
? { source: nameOrSource, args: nestedArgs }
|
|
1033
|
+
: { name: nameOrSource, args: nestedArgs };
|
|
1034
|
+
} else if (nameOrSource && typeof nameOrSource === "object") {
|
|
1035
|
+
const hasSource = typeof nameOrSource.source === "string" && nameOrSource.source.length > 0;
|
|
1036
|
+
const hasName = typeof nameOrSource.name === "string" && nameOrSource.name.length > 0;
|
|
1037
|
+
if (hasSource === hasName) throw new Error("workflow() object form requires exactly one source or name string");
|
|
1038
|
+
payload = hasSource
|
|
1039
|
+
? { source: nameOrSource.source, args: nameOrSource.args ?? nestedArgs }
|
|
1040
|
+
: { name: nameOrSource.name, args: nameOrSource.args ?? nestedArgs };
|
|
1041
|
+
} else {
|
|
1042
|
+
throw new Error("workflow() requires a static string name/source or workflow({ source, args })");
|
|
1043
|
+
}
|
|
1044
|
+
return await __host("workflow", payload);
|
|
1045
|
+
};
|
|
1046
|
+
// Supported host primitive for trusted autonomous drain adapters. The thin drain
|
|
1047
|
+
// workflow wrapper is intentionally thin and delegates domain control here.
|
|
1048
|
+
globalThis.drain = async function drain(options = {}) {
|
|
1049
|
+
return await __host("drain", options);
|
|
1050
|
+
};
|
|
1051
|
+
await __host("noop", {});
|
|
1052
|
+
`;
|
|
1053
|
+
|
|
1054
|
+
// Workflow bodies execute inside QuickJS. vm.dump() of a thrown Error yields {} because
|
|
1055
|
+
// Error.message is non-enumerable and is dropped by JSON-based marshaling, so a body
|
|
1056
|
+
// runtime error would otherwise surface as "[object Object]". Wrap the body so any thrown
|
|
1057
|
+
// value is captured HERE (where .message is intact) and returned as a plain sentinel
|
|
1058
|
+
// object whose enumerable string properties survive dump(). The host unwraps it below
|
|
1059
|
+
// and re-throws a real Error carrying the original message.
|
|
1060
|
+
const wrapped = `(async () => {\n"use strict";\n${prelude}\ntry {\n${body}\n} catch (__wfErr) {\n let __wfMsg = "";\n let __wfName = "Error";\n try {\n if (__wfErr && typeof __wfErr === "object") {\n if (typeof __wfErr.message === "string" && __wfErr.message.length > 0) __wfMsg = __wfErr.message;\n if (typeof __wfErr.name === "string" && __wfErr.name.length > 0) __wfName = __wfErr.name;\n } else if (typeof __wfErr === "string") {\n __wfMsg = __wfErr;\n }\n } catch {}\n if (!__wfMsg) {\n try { __wfMsg = String(__wfErr); } catch { __wfMsg = "[non-serializable workflow error]"; }\n }\n return { __workflowRuntimeError: true, message: __wfMsg, name: __wfName };\n}\n})()`;
|
|
1061
|
+
let promiseHandle;
|
|
1062
|
+
let valueHandle;
|
|
1063
|
+
try {
|
|
1064
|
+
armDeadline();
|
|
1065
|
+
const result = await vm.evalCodeAsync(wrapped, `${run.sourcePath}#workflow`);
|
|
1066
|
+
promiseHandle = vm.unwrapResult(result);
|
|
1067
|
+
armDeadline();
|
|
1068
|
+
executePendingJobs();
|
|
1069
|
+
const state = vm.getPromiseState(promiseHandle);
|
|
1070
|
+
if (state.type === "fulfilled") {
|
|
1071
|
+
valueHandle = state.value;
|
|
1072
|
+
} else if (state.type === "rejected") {
|
|
1073
|
+
valueHandle = state.error;
|
|
1074
|
+
// Defensive fallback for a top-level rejection that escaped the body try/catch
|
|
1075
|
+
// (e.g. a prelude failure). dump() drops Error.message, so recover it via the
|
|
1076
|
+
// handle before the boundary crossing degrades it to "[object Object]".
|
|
1077
|
+
throw new Error(readVmErrorMessage(vm, valueHandle));
|
|
1078
|
+
} else {
|
|
1079
|
+
armDeadline();
|
|
1080
|
+
const resolved = await vm.resolvePromise(promiseHandle);
|
|
1081
|
+
valueHandle = vm.unwrapResult(resolved);
|
|
1082
|
+
}
|
|
1083
|
+
const output = vm.dump(valueHandle);
|
|
1084
|
+
if (output !== null && typeof output === "object" && output.__workflowRuntimeError === true) {
|
|
1085
|
+
const msg = typeof output.message === "string" && output.message.length > 0 ? output.message : "[workflow runtime error]";
|
|
1086
|
+
const err = new Error(msg);
|
|
1087
|
+
if (typeof output.name === "string" && output.name.length > 0) err.name = output.name;
|
|
1088
|
+
throw err;
|
|
1089
|
+
}
|
|
1090
|
+
await rejectFloatingHostOpsIfAny();
|
|
1091
|
+
assertResultSize(output);
|
|
1092
|
+
return output;
|
|
1093
|
+
} finally {
|
|
1094
|
+
await settlePendingHostOps();
|
|
1095
|
+
valueHandle?.dispose();
|
|
1096
|
+
promiseHandle?.dispose();
|
|
1097
|
+
try {
|
|
1098
|
+
if (vm.alive) vm.setProp(vm.global, "__workflowHost", vm.undefined);
|
|
1099
|
+
} catch {}
|
|
1100
|
+
for (const handle of handles) handle.dispose();
|
|
1101
|
+
for (const deferred of deferreds) deferred.dispose();
|
|
1102
|
+
try {
|
|
1103
|
+
if (runtime?.alive) executePendingJobs();
|
|
1104
|
+
} catch {}
|
|
1105
|
+
try {
|
|
1106
|
+
if (vm.alive) vm.dispose();
|
|
1107
|
+
// executePendingJobs() can materialize additional async contexts; make sure none
|
|
1108
|
+
// remain rooted before freeing the per-run runtime.
|
|
1109
|
+
for (const context of runtime?.contextMap?.values?.() ?? []) {
|
|
1110
|
+
if (context?.alive) context.dispose();
|
|
1111
|
+
}
|
|
1112
|
+
} finally {
|
|
1113
|
+
if (runtime?.alive) runtime.dispose();
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function __setSandboxHostOpTestHook(hook) {
|
|
1119
|
+
sandboxHostOpTestHook = typeof hook === "function" ? hook : undefined;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
export {
|
|
1123
|
+
executeSandbox,
|
|
1124
|
+
runNestedWorkflow,
|
|
1125
|
+
drainGateStatus,
|
|
1126
|
+
maxHostCallsForRun,
|
|
1127
|
+
persistRunArtifacts,
|
|
1128
|
+
quickJSAsyncModule,
|
|
1129
|
+
newSandboxContext,
|
|
1130
|
+
__setSandboxHostOpTestHook,
|
|
1131
|
+
};
|