@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.75
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 +404 -0
- package/README.md +65 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +81 -9
- package/dist/cli/run-cli.js +442 -46
- package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +121 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/cleanup.js +8 -5
- package/dist/services/cli-operation-classifier.js +11 -1
- package/dist/services/code-checks.js +543 -96
- package/dist/services/controller-fanout.js +116 -0
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +50 -19
- package/dist/services/eval-snapshots.js +639 -60
- package/dist/services/execution-policy.js +177 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +413 -168
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-command.js +74 -6
- package/dist/services/lifecycle-invocations.js +637 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +138 -22
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +75 -29
- package/dist/services/migrations.js +1 -1
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/projects.js +7 -3
- package/dist/services/prompts.js +4 -2
- package/dist/services/protocols.js +11 -2
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +211 -0
- package/dist/services/run-controller-capture.js +134 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +31 -0
- package/dist/services/run-controller.js +733 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +327 -0
- package/dist/services/runs.js +280 -40
- package/dist/services/runtime-budget.js +362 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +543 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +210 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +12 -12
- package/dist/services/stage-context.js +2 -2
- package/dist/services/stage-lifecycle.js +32 -10
- package/dist/services/stage-pause.js +25 -13
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +149 -89
- package/dist/services/vnext-code.js +224 -216
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +135 -21
- package/dist/services/vnext-merge.js +258 -89
- package/dist/services/vnext-plan-review.js +75 -64
- package/dist/services/vnext-plan.js +77 -29
- package/dist/services/vnext-protocolize.js +21 -14
- package/dist/services/vnext-specify.js +26 -16
- package/dist/services/work-registry.js +605 -157
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +341 -31
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +20 -13
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createContext } from "../runtime/context.js";
|
|
5
|
+
import { getResourceDatabase } from "../storage/database.js";
|
|
6
|
+
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { finishManagedProcess, managedLeaseReleased, managedProcessBinding, managedProcessStatus, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
8
|
+
import { requestRuntimeScopeControl, runtimeBudgetStatus, scopeLocalProcessRole, validateRuntimeScopeManifest } from "./runtime-budget.js";
|
|
9
|
+
import { nativeInventoryVersion, recordRunControlIntent, retainedDaemonOperations, runControlStatus } from "./run-control.js";
|
|
10
|
+
import { requestRunControl } from "./run-control-worker.js";
|
|
11
|
+
import { adapterSessionId } from "./harness-adapter.js";
|
|
12
|
+
import { assertNativeCancellationReceipt, cancelControlledNativeSession, inspectControlledNativeSession } from "./native-session-control.js";
|
|
13
|
+
import { verifyRecoverySnapshot } from "./eval-snapshots.js";
|
|
14
|
+
import { captureScopeJournal } from "./runtime-scope-capture.js";
|
|
15
|
+
import { historicalDaemonSettlement, assertHistoricalDaemonSettlement } from "./native-daemon-history.js";
|
|
16
|
+
import { managedProviderBelongsToDaemon } from "./managed-daemon-binding.js";
|
|
17
|
+
const scopeInventoryIdentity = (records) => JSON.stringify(records.map(record => managedProcessBinding(record, "")).sort((a, b) => a.process_id.localeCompare(b.process_id)));
|
|
18
|
+
/** Persist the all-role drain boundary, separately from recovery readiness.
|
|
19
|
+
* Registry -> RUN lock order matches scope dispatch. No await/provider call is
|
|
20
|
+
* allowed while publishing: RUN resume and late registration cannot interleave.
|
|
21
|
+
*/
|
|
22
|
+
export function retainScopeDrain(context, control, processes, runs, native, probes) {
|
|
23
|
+
const registry = getResourceDatabase(resourceHome(context));
|
|
24
|
+
const targets = new Map();
|
|
25
|
+
const captures = [];
|
|
26
|
+
registry.beginWriteTransaction();
|
|
27
|
+
try {
|
|
28
|
+
for (const run of [...runs].sort((a, b) => String(a.runtime_home).localeCompare(String(b.runtime_home)))) {
|
|
29
|
+
const home = String(run.runtime_home);
|
|
30
|
+
let target = targets.get(home);
|
|
31
|
+
if (!target) {
|
|
32
|
+
target = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_RESOURCE_HOME: resourceHome(context), DD_FLOW_ENGINE_MODE: "1" });
|
|
33
|
+
try {
|
|
34
|
+
target.db.beginWriteTransaction();
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
target.db.close?.();
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
targets.set(home, target);
|
|
41
|
+
}
|
|
42
|
+
const project = target.db.get("SELECT root FROM projects WHERE id = ?", [run.project_id]);
|
|
43
|
+
const previous = run.receipt;
|
|
44
|
+
if (!project || !previous.control_id)
|
|
45
|
+
throw new AppError("runtime_scope_owner_unknown", "Drain requires the retained RUN control", 1);
|
|
46
|
+
const fresh = runControlStatus(target, { projectRoot: project.root, runId: String(run.run_id), controlId: previous.control_id });
|
|
47
|
+
if (fresh.settled !== true || fresh.control?.current !== true)
|
|
48
|
+
throw new AppError("runtime_scope_inventory_changed", "RUN control changed before scope drain publication", 1);
|
|
49
|
+
for (const record of processes) {
|
|
50
|
+
if (scopeRunProcessEvidence(record, [run], resourceHome(context)) && !scopeRunProcessEvidence(record, [{ ...run, receipt: fresh }], resourceHome(context)))
|
|
51
|
+
throw new AppError("runtime_scope_inventory_changed", "RUN no longer proves its captured process binding", 1);
|
|
52
|
+
}
|
|
53
|
+
run.receipt = fresh;
|
|
54
|
+
// Capture failure does not undo physical drain. Retain the failure beside
|
|
55
|
+
// the stop evidence, and keep resume fenced until capture is reconciled.
|
|
56
|
+
try {
|
|
57
|
+
const selected = fresh.control;
|
|
58
|
+
const savedRun = target.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.run_id]);
|
|
59
|
+
if (!selected.capture_path || !path.isAbsolute(selected.capture_path) || !selected.recovery_id || !savedRun)
|
|
60
|
+
throw new AppError("runtime_scope_capture_missing", "Scope RUN has no retained recovery capture", 1);
|
|
61
|
+
const manifest = path.join(selected.capture_path, "snapshot.json");
|
|
62
|
+
const digest = () => crypto.createHash("sha256").update(fs.readFileSync(manifest)).digest("hex");
|
|
63
|
+
const before = digest();
|
|
64
|
+
verifyRecoverySnapshot(selected.capture_path, { runId: String(run.run_id), recoveryId: selected.recovery_id, projectRoot: project.root, workspaceRoot: savedRun.workspace_root });
|
|
65
|
+
if (digest() !== before)
|
|
66
|
+
throw new AppError("runtime_scope_inventory_changed", "RUN capture manifest changed during validation", 1);
|
|
67
|
+
captures.push({ runtime_home: home, project_id: run.project_id, run_id: run.run_id, control_id: previous.control_id, recovery_id: selected.recovery_id, status: "verified", capture_path: selected.capture_path, manifest_sha256: before });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
captures.push({ runtime_home: home, project_id: run.project_id, run_id: run.run_id, control_id: previous.control_id, status: "pending", reason: error instanceof AppError ? error.code : "runtime_scope_capture_failed", message: String(error) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const latest = runtimeBudgetStatus(context, control.scope_id);
|
|
74
|
+
if (latest.fence || latest.control?.generation !== control.generation)
|
|
75
|
+
throw new AppError("runtime_scope_control_superseded", "Scope drain belongs to an old generation", 1);
|
|
76
|
+
if (scopeInventoryIdentity(processes) !== scopeInventoryIdentity(latest.processes) || latest.provider_turns.length)
|
|
77
|
+
throw new AppError("runtime_scope_inventory_changed", "Scope inventory changed before drain publication", 1);
|
|
78
|
+
for (const owner of native) {
|
|
79
|
+
if (owner.historical === true) {
|
|
80
|
+
assertHistoricalDaemonSettlement(context, control.scope_id, owner);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (typeof owner.state_dir !== "string" || nativeInventoryVersion(owner.state_dir) !== owner.inventory_version)
|
|
84
|
+
throw new AppError("runtime_scope_inventory_changed", "Native inventory changed before drain publication", 1);
|
|
85
|
+
}
|
|
86
|
+
for (const record of processes) {
|
|
87
|
+
const nativeProof = native.some(owner => owner.native_settled === true && (owner.process_id === record.id || Array.isArray(owner.processes) && owner.processes.some(item => item.process_id === record.id && item.native_settled === true)));
|
|
88
|
+
const probeProof = probes.some(probe => probe.process_id === record.id && probe.physical_settled === true) && !processTreeIsAlive(record);
|
|
89
|
+
if (!nativeProof && !probeProof && !scopeRunProcessEvidence(record, runs, resourceHome(context)))
|
|
90
|
+
throw new AppError("runtime_scope_resource_unsettled", "Scope drain is missing an owned process settlement proof", 1);
|
|
91
|
+
}
|
|
92
|
+
let manifest = null, journal = null, manifestReason = "scope_manifest_required";
|
|
93
|
+
if (latest.manifest) {
|
|
94
|
+
try {
|
|
95
|
+
manifest = validateRuntimeScopeManifest(latest.manifest, control.scope_id);
|
|
96
|
+
journal = captureScopeJournal(context, control, manifest);
|
|
97
|
+
manifestReason = "scope_operation_reconciliation_required";
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
manifestReason = error instanceof AppError ? error.code : "runtime_scope_manifest_unavailable";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const resumableNative = native.filter(owner => owner.historical !== true);
|
|
104
|
+
const capture = { manifest, journal, run_captures: captures, native_bindings: resumableNative.map(owner => ({ process_id: owner.process_id, role: owner.role, state_dir: owner.state_dir, daemon_id: owner.daemon_id, runtime_owner: owner.runtime_owner, inventory_version: owner.inventory_version, sessions: owner.sessions, operations: owner.operations })), probe_operations: probes, pending_reasons: [...new Set([manifestReason, ...captures.filter(item => item.status !== "verified").map(item => String(item.reason)), ...(resumableNative.length ? ["non_work_recovery_required"] : []), ...(probes.length ? ["probe_reconciliation_required"] : [])])] };
|
|
105
|
+
const receipt = { scope_id: control.scope_id, generation: control.generation, requested_mode: control.requested_mode, observed_at: context.now(), physical_settled: true, recovery_ready: false, process_bindings: processes.map(record => managedProcessBinding(record, resourceHome(context))), runs, native, probes, capture };
|
|
106
|
+
const key = crypto.createHash("sha256").update(JSON.stringify([control.scope_id, control.generation])).digest("hex");
|
|
107
|
+
registry.run("INSERT INTO managed_resources (resource_kind,resource_key,owner_id,lease_token,lease_expires_at,process_id,metadata_json,created_at,updated_at) VALUES ('scope-control-drain',?,?,?,?,NULL,?,?,?) ON CONFLICT(resource_kind,resource_key) DO UPDATE SET metadata_json = excluded.metadata_json, updated_at = excluded.updated_at", [key, control.scope_id, key, context.now(), JSON.stringify(receipt), context.now(), context.now()]);
|
|
108
|
+
registry.exec("COMMIT");
|
|
109
|
+
return receipt;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
registry.exec("ROLLBACK");
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
for (const target of targets.values()) {
|
|
117
|
+
try {
|
|
118
|
+
target.db.exec("ROLLBACK");
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
target.db.close?.();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function scopeRunProcessEvidence(record, runs, registryHome) {
|
|
127
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
128
|
+
if ((!managedLeaseReleased(record) && (!record.pid || !record.pid_started_at)) || !record.project_id || !record.run_id || !metadata.dd_flow_home)
|
|
129
|
+
return null;
|
|
130
|
+
for (const run of runs) {
|
|
131
|
+
if (run.runtime_home !== metadata.dd_flow_home || run.project_id !== record.project_id || run.run_id !== record.run_id)
|
|
132
|
+
continue;
|
|
133
|
+
const receipt = run.receipt;
|
|
134
|
+
if (receipt?.settled !== true || receipt.control?.current !== true || receipt.scope?.project_id !== record.project_id || receipt.scope.run_id !== record.run_id)
|
|
135
|
+
continue;
|
|
136
|
+
const bindingSignature = managedProcessBinding(record, registryHome).registration_sha256;
|
|
137
|
+
if (receipt.control.settlement?.process_bindings?.some(binding => binding.resource_home === registryHome && binding.process_id === record.id && binding.registration_sha256 === bindingSignature))
|
|
138
|
+
return { source: "run_control", process_id: record.id };
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
export async function controlRuntimeScopeLocalProbes(context, scopeId, generation, mode) {
|
|
143
|
+
const registry = getResourceDatabase(resourceHome(context));
|
|
144
|
+
const inventory = runtimeBudgetStatus(context, scopeId).processes;
|
|
145
|
+
return await Promise.all(inventory.filter(record => ["eval-baseline", "eval-observer"].includes(record.kind)).map(async (record) => {
|
|
146
|
+
try {
|
|
147
|
+
const validate = () => {
|
|
148
|
+
const head = registry.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
|
|
149
|
+
if (!head || JSON.parse(head.metadata_json).generation !== generation || JSON.parse(head.metadata_json).requested_mode !== mode || registry.get("SELECT 1 FROM managed_resources WHERE resource_kind = 'provider-fence' AND resource_key = ?", [scopeId]))
|
|
150
|
+
throw new AppError("runtime_scope_control_superseded", "Local probe control belongs to an old generation", 1);
|
|
151
|
+
const current = registry.get("SELECT * FROM managed_processes WHERE id = ?", [record.id]);
|
|
152
|
+
if (!scopeLocalProcessRole(record) || !record.pid || !record.pid_started_at || !current || current.kind !== record.kind || current.owner_id !== record.owner_id || current.operation_id !== record.operation_id || current.project_id !== record.project_id || current.run_id !== record.run_id || current.lease_token !== record.lease_token || current.pid !== record.pid || current.pid_started_at !== record.pid_started_at || current.metadata_json !== record.metadata_json)
|
|
153
|
+
throw new AppError("runtime_scope_owner_unknown", "Local process requires its exact retained physical registration", 1);
|
|
154
|
+
return current;
|
|
155
|
+
};
|
|
156
|
+
let current = validate();
|
|
157
|
+
if (mode === "pause" && processTreeIsAlive(current))
|
|
158
|
+
return { process_id: record.id, physical_settled: false, reason: "local_probe_running" };
|
|
159
|
+
const operationId = `scope-probe:${crypto.createHash("sha256").update(JSON.stringify([scopeId, generation, record.id, record.lease_token, record.pid_started_at])).digest("hex")}`;
|
|
160
|
+
registry.beginWriteTransaction();
|
|
161
|
+
try {
|
|
162
|
+
current = validate();
|
|
163
|
+
registry.run("INSERT OR IGNORE INTO managed_resources (resource_kind,resource_key,owner_id,lease_token,lease_expires_at,process_id,metadata_json,created_at,updated_at) VALUES ('scope-control-operation',?,?,?,?,NULL,?,?,?)", [operationId, scopeId, record.lease_token, context.now(), JSON.stringify({ scope_id: scopeId, generation, process_id: record.id, operation: mode === "stop" ? "probe.stop" : "probe.observe", status: "dispatching" }), context.now(), context.now()]);
|
|
164
|
+
registry.exec("COMMIT");
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
registry.exec("ROLLBACK");
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
// A prior owner may have died after recording intent but before signalling.
|
|
171
|
+
// Repeating an identity-checked stop never redispatches productive work.
|
|
172
|
+
if (mode === "stop" && processTreeIsAlive(current))
|
|
173
|
+
await stopManagedProcess(context, { id: record.id, leaseToken: record.lease_token, force: false, graceMs: 500, reason: operationId });
|
|
174
|
+
current = validate();
|
|
175
|
+
if (processTreeIsAlive(current))
|
|
176
|
+
throw new AppError("runtime_scope_resource_unsettled", "Local probe has not exited", 1);
|
|
177
|
+
if (!finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: current.state === "failed" ? "failed" : "stopped", reason: operationId }))
|
|
178
|
+
throw new AppError("runtime_scope_owner_changed", "Probe ownership changed before settlement", 1);
|
|
179
|
+
const receipt = { process_id: record.id, role: scopeLocalProcessRole(record), physical_settled: true, operation_id: operationId, outcome: "requires_reconciliation" };
|
|
180
|
+
registry.run("UPDATE managed_resources SET metadata_json = json_set(metadata_json, '$.status', 'completed', '$.receipt', json(?)), updated_at = ? WHERE resource_kind = 'scope-control-operation' AND resource_key = ?", [JSON.stringify(receipt), context.now(), operationId]);
|
|
181
|
+
return receipt;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
return { process_id: record.id, physical_settled: false, reason: error instanceof AppError ? error.code : "runtime_scope_control_failed", message: String(error) };
|
|
185
|
+
}
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
export async function controlRuntimeScopeNativeOwners(context, scopeId, generation, action = "observe") {
|
|
189
|
+
const registry = getResourceDatabase(resourceHome(context));
|
|
190
|
+
const assertCurrent = () => {
|
|
191
|
+
const row = registry.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
|
|
192
|
+
if (!row || JSON.parse(row.metadata_json).generation !== generation || action === "stop" && JSON.parse(row.metadata_json).requested_mode !== "stop" || registry.get("SELECT 1 FROM managed_resources WHERE resource_kind = 'provider-fence' AND resource_key = ?", [scopeId]))
|
|
193
|
+
throw new AppError("runtime_scope_control_superseded", "Scope observation belongs to an old generation", 1);
|
|
194
|
+
};
|
|
195
|
+
const inventory = runtimeBudgetStatus(context, scopeId).processes;
|
|
196
|
+
return await Promise.all(inventory.filter(record => record.kind.endsWith("-daemon") && !record.run_id).map(async (record) => {
|
|
197
|
+
try {
|
|
198
|
+
assertCurrent();
|
|
199
|
+
const historical = historicalDaemonSettlement(context, scopeId, record);
|
|
200
|
+
if (historical) {
|
|
201
|
+
assertCurrent();
|
|
202
|
+
return historical;
|
|
203
|
+
}
|
|
204
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
205
|
+
if (!["judge", "probe"].includes(metadata.role ?? "") || !metadata.state_dir || !path.isAbsolute(metadata.state_dir) || !metadata.adapter_executable || !path.isAbsolute(metadata.adapter_executable) || metadata.dispatch_barrier !== "run-generation@1")
|
|
206
|
+
throw new AppError("runtime_scope_owner_unknown", "Non-Work observation requires a frozen native owner and dispatch barrier", 1);
|
|
207
|
+
const file = path.join(metadata.state_dir, "daemon.json"), before = fs.readFileSync(file, "utf8");
|
|
208
|
+
const initialNativeVersion = nativeInventoryVersion(metadata.state_dir, []);
|
|
209
|
+
if (fs.readFileSync(file, "utf8") !== before)
|
|
210
|
+
throw new AppError("runtime_scope_inventory_changed", "Native inventory changed while its owner was being resolved", 1);
|
|
211
|
+
const state = JSON.parse(before);
|
|
212
|
+
const owner = state.config?.runtime_owner;
|
|
213
|
+
if (!state.daemon_id || state.pid !== record.pid || !record.pid_started_at || state.resource_process?.id !== record.id || state.resource_process.lease_token !== record.lease_token || !owner || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.budget?.scope_id !== scopeId || owner.state_dir !== metadata.state_dir || owner.dd_flow_home !== metadata.dd_flow_home || owner.resource_home !== resourceHome(context) || owner.adapter_executable !== metadata.adapter_executable || (owner.project_id ?? null) !== record.project_id || (owner.run_id ?? null) !== record.run_id || (owner.work_id ?? null) !== record.work_id || !record.operation_id?.startsWith(`${owner.operation_id}:`))
|
|
214
|
+
throw new AppError("runtime_scope_owner_unknown", "Native observation does not match retained ownership", 1);
|
|
215
|
+
const assertOwner = () => {
|
|
216
|
+
assertCurrent();
|
|
217
|
+
const all = managedProcessStatus(context);
|
|
218
|
+
if (!all.some(item => item.id === record.id && item.lease_token === record.lease_token && item.pid === record.pid && item.pid_started_at === record.pid_started_at && item.metadata_json === record.metadata_json))
|
|
219
|
+
throw new AppError("runtime_scope_owner_changed", "Native registration changed during observation", 1);
|
|
220
|
+
for (const item of all) {
|
|
221
|
+
const saved = JSON.parse(item.metadata_json);
|
|
222
|
+
if (saved.state_dir === metadata.state_dir && (saved.budget?.scope_id !== scopeId || item.owner_id !== record.owner_id))
|
|
223
|
+
throw new AppError("runtime_scope_owner_unknown", "Native directory has a foreign registration", 1);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
assertOwner();
|
|
227
|
+
if (!Array.isArray(state.sessions) || !state.sessions.length)
|
|
228
|
+
throw new AppError("daemon_inventory_invalid", "Native Session inventory is unknown", 1);
|
|
229
|
+
const ids = state.sessions.map(item => typeof item === "string" && item.trim() ? item : adapterSessionId(item));
|
|
230
|
+
if (ids.some(id => !id) || new Set(ids).size !== ids.length)
|
|
231
|
+
throw new AppError("daemon_inventory_invalid", "Native Session inventory is ambiguous", 1);
|
|
232
|
+
const env = { ...context.env, DD_FLOW_HOME: owner.dd_flow_home, DD_FLOW_BIN: owner.dd_flow_bin, DD_FLOW_RESOURCE_HOME: owner.resource_home };
|
|
233
|
+
delete env.DD_FLOW_CONTROL_TOKEN;
|
|
234
|
+
delete env.DD_FLOW_CONTROLLER_TOKEN;
|
|
235
|
+
const sessions = await Promise.all(ids.map(async (id) => {
|
|
236
|
+
try {
|
|
237
|
+
let cancellation;
|
|
238
|
+
let cancellationError;
|
|
239
|
+
if (action === "stop") {
|
|
240
|
+
const operationId = `scope-cancel:${crypto.createHash("sha256").update(JSON.stringify([scopeId, generation, record.id, state.daemon_id, id])).digest("hex")}`;
|
|
241
|
+
const now = context.now();
|
|
242
|
+
let inserted = false;
|
|
243
|
+
registry.beginWriteTransaction();
|
|
244
|
+
try {
|
|
245
|
+
assertOwner();
|
|
246
|
+
inserted = registry.run("INSERT OR IGNORE INTO managed_resources (resource_kind,resource_key,owner_id,lease_token,lease_expires_at,process_id,metadata_json,created_at,updated_at) VALUES ('scope-control-operation',?,?,?,?,NULL,?,?,?)", [operationId, scopeId, record.lease_token, now, JSON.stringify({ scope_id: scopeId, generation, process_id: record.id, daemon_id: state.daemon_id, session_id: id, status: "dispatching" }), now, now]).changes === 1;
|
|
247
|
+
registry.exec("COMMIT");
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
registry.exec("ROLLBACK");
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const saved = JSON.parse(registry.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-operation' AND resource_key = ?", [operationId]).metadata_json);
|
|
255
|
+
if (saved.status === "completed") {
|
|
256
|
+
assertNativeCancellationReceipt(saved.receipt, id);
|
|
257
|
+
cancellation = saved.receipt;
|
|
258
|
+
}
|
|
259
|
+
else if (saved.status === "failed") {
|
|
260
|
+
cancellation = saved.receipt;
|
|
261
|
+
try {
|
|
262
|
+
cancellationError = JSON.parse(saved.error ?? "");
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
cancellationError = { code: "cancellation_outcome_unknown", message: saved.error ?? "Retained cancellation has no conclusive outcome" };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
const result = await cancelControlledNativeSession({ executable: metadata.adapter_executable, sessionId: id, stateDir: metadata.state_dir, env, operationId, observe: !inserted, assertCurrent: assertOwner });
|
|
270
|
+
registry.run("UPDATE managed_resources SET metadata_json = json_set(metadata_json, '$.status', ?, '$.receipt', json(?)), updated_at = ? WHERE resource_kind = 'scope-control-operation' AND resource_key = ?", [result.status, JSON.stringify(result.receipt), context.now(), operationId]);
|
|
271
|
+
cancellation = result.receipt;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
cancellationError = { code: error instanceof AppError ? error.code : "cancellation_outcome_unknown", message: String(error) };
|
|
276
|
+
registry.run("UPDATE managed_resources SET metadata_json = json_set(metadata_json, '$.status', 'outcome_unknown', '$.error', ?), updated_at = ? WHERE resource_kind = 'scope-control-operation' AND resource_key = ?", [JSON.stringify(cancellationError), context.now(), operationId]);
|
|
277
|
+
}
|
|
278
|
+
if (cancellationError?.code === "controller_operation_mismatch")
|
|
279
|
+
throw new AppError("controller_operation_mismatch", "Cancellation evidence belongs to another operation or Session", 1);
|
|
280
|
+
}
|
|
281
|
+
const receipt = await inspectControlledNativeSession({ executable: metadata.adapter_executable, sessionId: id, stateDir: metadata.state_dir, env, operationId: `scope:${scopeId}:${generation}:inspect:${crypto.randomUUID()}`, assertCurrent: assertOwner });
|
|
282
|
+
// Physical settlement is determined by this fresh inspection. A past
|
|
283
|
+
// cancel ACK (or an inconclusive cancel outcome) is retained as
|
|
284
|
+
// evidence, never substituted for the state of the native tree.
|
|
285
|
+
return { session_id: id, settled: receipt.settled === true, receipt, ...(cancellation ? { cancellation } : {}), ...(cancellationError ? { cancellation_error: cancellationError } : {}) };
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
return { session_id: id, settled: false, reason: error instanceof AppError ? error.code : "runtime_scope_session_control_failed", message: String(error) };
|
|
289
|
+
}
|
|
290
|
+
}));
|
|
291
|
+
assertOwner();
|
|
292
|
+
if (nativeInventoryVersion(metadata.state_dir, []) !== initialNativeVersion)
|
|
293
|
+
throw new AppError("runtime_scope_inventory_changed", "Native inventory changed during observation", 1);
|
|
294
|
+
const operations = retainedDaemonOperations(metadata.state_dir);
|
|
295
|
+
const unsettled = operations.filter(operation => !["completed", "failed"].includes(String(operation.state)));
|
|
296
|
+
const nativeSettled = sessions.every(session => session.settled) && !unsettled.length;
|
|
297
|
+
const processes = inventory.filter(item => JSON.parse(item.metadata_json).state_dir === metadata.state_dir).map(item => {
|
|
298
|
+
const owned = item.id === record.id || managedProviderBelongsToDaemon(item, record, state) && item.operation_id?.startsWith(`${owner.operation_id}:`) === true;
|
|
299
|
+
return { process_id: item.id, native_settled: owned && nativeSettled, reason: !owned ? "runtime_scope_owner_unknown" : nativeSettled ? null : "native_session_settlement_required" };
|
|
300
|
+
});
|
|
301
|
+
return { process_id: record.id, role: metadata.role, state_dir: metadata.state_dir, daemon_id: state.daemon_id, runtime_owner: owner, inventory_version: nativeInventoryVersion(metadata.state_dir, operations), native_settled: nativeSettled, processes, sessions, operations, unsettled_operations: unsettled.map(operation => operation.operation_id) };
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
return { process_id: record.id, native_settled: false, reason: error instanceof AppError ? error.code : "runtime_scope_observation_failed", message: String(error) };
|
|
305
|
+
}
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
/** Scope routing only: existing RUN workers own physical drain and capture. */
|
|
309
|
+
export async function controlRuntimeScopeRuns(context, scopeId, requestId, mode, reconcileId) {
|
|
310
|
+
const control = requestRuntimeScopeControl(context, scopeId, requestId, mode);
|
|
311
|
+
const registry = getResourceDatabase(resourceHome(context));
|
|
312
|
+
const inventory = runtimeBudgetStatus(context, scopeId).processes;
|
|
313
|
+
const runs = [];
|
|
314
|
+
const seen = new Set();
|
|
315
|
+
for (const record of inventory.filter(item => item.kind === "run-controller")) {
|
|
316
|
+
let target;
|
|
317
|
+
try {
|
|
318
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
319
|
+
if (metadata.role !== "controller" || metadata.controller_id !== record.owner_id || !record.project_id || !record.run_id || !metadata.dd_flow_home || !path.isAbsolute(metadata.dd_flow_home) || !fs.existsSync(path.join(metadata.dd_flow_home, "db.sqlite")))
|
|
320
|
+
throw new AppError("runtime_scope_owner_unknown", "Scope RUN requires its retained runtime home and controller registration", 1);
|
|
321
|
+
const key = JSON.stringify([metadata.dd_flow_home, record.project_id, record.run_id]);
|
|
322
|
+
if (seen.has(key))
|
|
323
|
+
continue;
|
|
324
|
+
target = createContext({ ...context.env, DD_FLOW_HOME: metadata.dd_flow_home, DD_FLOW_RESOURCE_HOME: resourceHome(context), DD_FLOW_ENGINE_MODE: "1" });
|
|
325
|
+
const owner = target.db.get("SELECT manifest_json, process_id, process_lease_token FROM run_controllers WHERE controller_id = ? AND project_id = ? AND run_id = ?", [record.owner_id, record.project_id, record.run_id]);
|
|
326
|
+
const manifest = owner && JSON.parse(owner.manifest_json);
|
|
327
|
+
const project = target.db.get("SELECT root FROM projects WHERE id = ?", [record.project_id]);
|
|
328
|
+
if (!owner || owner.process_id !== record.id || owner.process_lease_token !== record.lease_token || manifest?.resource_home !== resourceHome(context) || manifest.budget?.scope_id !== scopeId || !project?.root)
|
|
329
|
+
throw new AppError("runtime_scope_owner_unknown", "Scope RUN registration does not match the retained controller", 1);
|
|
330
|
+
const runRequestId = `scope:${crypto.createHash("sha256").update(JSON.stringify([scopeId, control.generation, key])).digest("hex")}`;
|
|
331
|
+
let input = { projectRoot: project.root, runId: record.run_id, requestId: runRequestId, action: mode };
|
|
332
|
+
// Lock order is resource registry then RUN database. No provider or await
|
|
333
|
+
// occurs here; a newer scope request cannot interleave RUN intent creation.
|
|
334
|
+
registry.beginWriteTransaction();
|
|
335
|
+
try {
|
|
336
|
+
const current = registry.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
|
|
337
|
+
if (!current || JSON.parse(current.metadata_json).generation !== control.generation || registry.get("SELECT 1 FROM managed_resources WHERE resource_kind = 'provider-fence' AND resource_key = ?", [scopeId]))
|
|
338
|
+
throw new AppError("runtime_scope_control_superseded", "Scope request no longer owns admission", 1);
|
|
339
|
+
if (!registry.get("SELECT 1 FROM managed_processes WHERE id = ? AND lease_token = ? AND owner_id = ? AND project_id = ? AND run_id = ? AND metadata_json = ?", [record.id, record.lease_token, record.owner_id, record.project_id, record.run_id, record.metadata_json]))
|
|
340
|
+
throw new AppError("runtime_scope_owner_changed", "RUN owner changed before scope intent dispatch", 1);
|
|
341
|
+
// A narrower caller may already have fenced this RUN. Reuse its exact
|
|
342
|
+
// intent; a scope stop still escalates a pause through the RUN arbiter.
|
|
343
|
+
const active = runControlStatus(target, input).control;
|
|
344
|
+
if (active?.current && ["requested", "settled"].includes(active.status) && (active.requested_mode === mode || active.requested_mode === "stop")) {
|
|
345
|
+
input = { ...input, requestId: active.request_id, action: active.requested_mode,
|
|
346
|
+
...(active.requested_mode === "stop" ? { force: active.request.force ?? false } : { graceMs: active.request.grace_ms ?? 30_000, ...(active.request.escalate ? { escalate: active.request.escalate } : {}) }) };
|
|
347
|
+
}
|
|
348
|
+
recordRunControlIntent(target, input);
|
|
349
|
+
registry.exec("COMMIT");
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
registry.exec("ROLLBACK");
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
seen.add(key);
|
|
356
|
+
const receipt = await requestRunControl(target, { ...input, waitMs: 0, ...(reconcileId ? { reconcileId: crypto.createHash("sha256").update(JSON.stringify([scopeId, control.generation, reconcileId])).digest("hex") } : {}) });
|
|
357
|
+
runs.push({ runtime_home: metadata.dd_flow_home, project_id: record.project_id, run_id: record.run_id, request_id: input.requestId, receipt });
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
runs.push({ process_id: record.id, run_id: record.run_id, pending: true, reason: error instanceof AppError ? error.code : "runtime_scope_control_failed", message: String(error) });
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
target?.db.close?.();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Individual receipts are not an all-role settlement proof. Judges, probes,
|
|
367
|
+
// late registrations and unmaterialized executions still require scope drain.
|
|
368
|
+
const [native, probes] = await Promise.all([
|
|
369
|
+
controlRuntimeScopeNativeOwners(context, scopeId, control.generation, mode === "stop" ? "stop" : "observe"),
|
|
370
|
+
controlRuntimeScopeLocalProbes(context, scopeId, control.generation, mode)
|
|
371
|
+
]);
|
|
372
|
+
for (const run of runs) {
|
|
373
|
+
if (run.pending || typeof run.runtime_home !== "string" || typeof run.run_id !== "string")
|
|
374
|
+
continue;
|
|
375
|
+
let target;
|
|
376
|
+
try {
|
|
377
|
+
target = createContext({ ...context.env, DD_FLOW_HOME: run.runtime_home, DD_FLOW_RESOURCE_HOME: resourceHome(context), DD_FLOW_ENGINE_MODE: "1" }, "read_existing");
|
|
378
|
+
const project = target.db.get("SELECT root FROM projects WHERE id = ?", [run.project_id]);
|
|
379
|
+
const selected = run.receipt;
|
|
380
|
+
if (!project || !selected.control_id)
|
|
381
|
+
throw new AppError("runtime_scope_owner_unknown", "Scope RUN has no retained control identity", 1);
|
|
382
|
+
run.receipt = runControlStatus(target, { projectRoot: project.root, runId: run.run_id, controlId: selected.control_id });
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
run.pending = true;
|
|
386
|
+
run.receipt = null;
|
|
387
|
+
run.reason = error instanceof AppError ? error.code : "runtime_scope_observation_failed";
|
|
388
|
+
run.message = String(error);
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
target?.db.close?.();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const owner of native) {
|
|
395
|
+
if (typeof owner.state_dir !== "string" || typeof owner.inventory_version !== "string")
|
|
396
|
+
continue;
|
|
397
|
+
try {
|
|
398
|
+
if (owner.historical === true) {
|
|
399
|
+
assertHistoricalDaemonSettlement(context, scopeId, owner);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (nativeInventoryVersion(owner.state_dir) !== owner.inventory_version)
|
|
403
|
+
throw new AppError("runtime_scope_inventory_changed", "Native inventory changed while other owners were draining", 1);
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
owner.native_settled = false;
|
|
407
|
+
owner.reason = error instanceof AppError ? error.code : "runtime_scope_observation_failed";
|
|
408
|
+
if (Array.isArray(owner.processes))
|
|
409
|
+
for (const item of owner.processes) {
|
|
410
|
+
item.native_settled = false;
|
|
411
|
+
item.reason = owner.reason;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const latest = runtimeBudgetStatus(context, scopeId);
|
|
416
|
+
const current = !latest.fence && latest.control?.generation === control.generation;
|
|
417
|
+
const identity = (records) => JSON.stringify(records.map(record => [record.id, record.kind, record.owner_id, record.lease_token, record.pid, record.pid_started_at, record.project_id, record.run_id, record.work_id, record.check_id, record.operation_id, record.metadata_json]));
|
|
418
|
+
const inventoryChanged = identity(inventory) !== identity(latest.processes);
|
|
419
|
+
const nodes = latest.processes.map(record => {
|
|
420
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
421
|
+
const probe = probes.find(item => item.process_id === record.id);
|
|
422
|
+
const daemon = native.find(item => item.process_id === record.id);
|
|
423
|
+
const processProofs = native.flatMap(item => Array.isArray(item.processes) ? item.processes : []).filter(item => item.process_id === record.id);
|
|
424
|
+
const nativeProcess = processProofs.find(item => item.native_settled === true) ?? processProofs[0];
|
|
425
|
+
const run = scopeRunProcessEvidence(record, runs, resourceHome(context));
|
|
426
|
+
// These observations cover their exact registered resource, not arbitrary
|
|
427
|
+
// siblings sharing a RUN or native directory. Unmapped owners stay pending.
|
|
428
|
+
const evidence = probe ?? nativeProcess ?? daemon;
|
|
429
|
+
const observed = probe?.physical_settled === true || nativeProcess?.native_settled === true || daemon?.native_settled === true || run !== null;
|
|
430
|
+
const valid = current && !inventoryChanged;
|
|
431
|
+
return { process_id: record.id, kind: record.kind, role: metadata.role ?? "unknown", run_id: record.run_id,
|
|
432
|
+
status: valid && observed ? "completed" : "pending", settled: valid && observed,
|
|
433
|
+
source: probe ? "local_probe" : daemon ? "native_owner" : nativeProcess ? "native_provider" : run ? "run_control" : null,
|
|
434
|
+
reason: !current ? "runtime_scope_control_superseded" : inventoryChanged ? "runtime_scope_inventory_changed" : observed ? null : evidence?.reason ?? "runtime_scope_resource_settlement_required" };
|
|
435
|
+
});
|
|
436
|
+
const providerTurns = latest.provider_turns;
|
|
437
|
+
let drain = null;
|
|
438
|
+
let drainError = null;
|
|
439
|
+
if (current && !inventoryChanged && !providerTurns.length && nodes.every(node => node.settled) && runs.every(run => !run.pending && run.receipt?.settled === true)) {
|
|
440
|
+
try {
|
|
441
|
+
drain = retainScopeDrain(context, control, latest.processes, runs, native, probes);
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
drainError = error instanceof AppError ? error.code : "runtime_scope_drain_publication_failed";
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// A drained process inventory alone misses admitted remote work whose local
|
|
448
|
+
// owner exited. Keep these obligations in the durable worker observation.
|
|
449
|
+
return { scope_id: scopeId, control, current, inventory_changed: inventoryChanged, nodes, runs, native, probes, provider_turns: providerTurns, drain, settled: false, pending_reason: !current ? "runtime_scope_control_superseded" : inventoryChanged ? "runtime_scope_inventory_changed" : providerTurns.length ? "scope_provider_turn_reconciliation_required" : drainError ?? (drain ? "scope_capture_required" : "scope_drain_required") };
|
|
450
|
+
}
|