@deksden-com/dd-flow-cli 0.9.0-beta.152 → 0.9.0-beta.156
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 +31 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +33 -3
- package/dist/cli/command-metadata.js +73 -2
- package/dist/cli/delivery.js +108 -0
- package/dist/cli/diagnostic-invocation.js +23 -7
- package/dist/cli/diagnostic.js +14 -3
- package/dist/cli/help.js +117 -1
- package/dist/cli/hook-ingress.js +36 -49
- package/dist/cli/interactive-invocation.js +33 -0
- package/dist/cli/interactive.js +114 -0
- package/dist/cli/memory-fix-invocation.js +52 -0
- package/dist/cli/memory-fix.js +100 -0
- package/dist/cli/memory-flow-admission.js +7 -3
- package/dist/cli/memory-init.js +5 -4
- package/dist/cli/native-hook-ingress.js +5 -1
- package/dist/cli/native-hook-preparation.js +31 -4
- package/dist/cli/run-cli.js +274 -19
- package/dist/cli.js +17 -3
- package/dist/domain/delivery-flows.js +8 -0
- package/dist/domain/distill-flow.js +20 -0
- package/dist/domain/interactive-flow.js +1 -0
- package/dist/domain/memory-fix-flow.js +2 -0
- package/dist/domain/review-fix-flow.js +2 -0
- package/dist/domain/source-snapshot-flows.js +4 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +18 -3
- package/dist/harness-runtime/bin/dd-grok.mjs +15 -5
- package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
- package/dist/harness-runtime/lib/agy-error-boundary.mjs +18 -10
- package/dist/harness-runtime/lib/agy-native-inspection.mjs +12 -3
- package/dist/harness-runtime/lib/agy-native-inventory.mjs +19 -0
- package/dist/harness-runtime/lib/agy-terminal-envelope.mjs +13 -6
- package/dist/harness-runtime/lib/agy-transcript-stream.mjs +54 -0
- package/dist/harness-runtime/lib/daemon-observation.d.mts +1 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +12 -4
- package/dist/harness-runtime/lib/daemon-operations.mjs +20 -5
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +371 -114
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +43 -3
- package/dist/harness-runtime/lib/dd-codex.mjs +36 -4
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +25 -11
- package/dist/harness-runtime/lib/dd-droid.mjs +124 -11
- package/dist/harness-runtime/lib/dd-grok-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +137 -25
- package/dist/harness-runtime/lib/dd-grok.mjs +4 -2
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +115 -17
- package/dist/harness-runtime/lib/dd-opencode.mjs +10 -7
- package/dist/harness-runtime/lib/dd-zcode-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +115 -22
- package/dist/harness-runtime/lib/dd-zcode.mjs +75 -36
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +15 -5
- package/dist/harness-runtime/lib/delivery-command.mjs +84 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +0 -19
- package/dist/harness-runtime/lib/droid-observation.mjs +9 -5
- package/dist/harness-runtime/lib/lifecycle-contract.d.mts +1 -0
- package/dist/harness-runtime/lib/lifecycle-contract.mjs +2 -1
- package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -2
- package/dist/harness-runtime/lib/native-hook-command.mjs +63 -8
- package/dist/harness-runtime/lib/native-streams.mjs +3 -10
- package/dist/harness-runtime/lib/opencode-plugin.mjs +36 -9
- package/dist/harness-runtime/lib/operation-errors.mjs +13 -0
- package/dist/harness-runtime/lib/operation-progress.mjs +6 -2
- package/dist/harness-runtime/lib/process-json.mjs +4 -5
- package/dist/harness-runtime/lib/tool-observations.mjs +20 -10
- package/dist/harness-runtime/lib/zcode-native-hook-ownership.mjs +109 -0
- package/dist/schemas/code-work-result.schema.json +2 -1
- package/dist/schemas/delivery-stage-result.schema.json +286 -0
- package/dist/schemas/distill-stage-result.schema.json +667 -0
- package/dist/schemas/distill-work-result.schema.json +428 -0
- package/dist/schemas/execution-bundle.schema.json +6 -2
- package/dist/schemas/interactive-stage-result.schema.json +47 -0
- package/dist/schemas/interactive-work-result.schema.json +63 -0
- package/dist/schemas/mb-fix-stage-result.schema.json +93 -0
- package/dist/schemas/project-flow-pack-manifest.schema.json +2 -2
- package/dist/schemas/review-fix-stage-result.schema.json +210 -0
- package/dist/schemas/run-control-receipt.schema.json +1 -0
- package/dist/services/assigned-lifecycle.js +382 -0
- package/dist/services/check-runner-evidence.js +45 -8
- package/dist/services/cli-operation-classifier.js +6 -1
- package/dist/services/code-checks.js +46 -29
- package/dist/services/codex-hook-delivery.js +11 -2
- package/dist/services/controller-child-repair.js +95 -0
- package/dist/services/controller-execution.js +4 -1
- package/dist/services/controller-fanout.js +19 -9
- package/dist/services/controller-memory-worker.js +6 -4
- package/dist/services/controller-result-authority.js +122 -0
- package/dist/services/dashboard.js +4 -2
- package/dist/services/delivery-access.js +123 -0
- package/dist/services/delivery-approval.js +68 -0
- package/dist/services/delivery-artifact.js +86 -0
- package/dist/services/delivery-checks.js +178 -0
- package/dist/services/delivery-execution.js +181 -0
- package/dist/services/delivery-intents.js +212 -0
- package/dist/services/delivery-operation.js +81 -0
- package/dist/services/delivery-policy.js +91 -0
- package/dist/services/delivery-procedures.js +43 -0
- package/dist/services/delivery-request.js +99 -0
- package/dist/services/delivery-result.js +144 -0
- package/dist/services/delivery-stage-commands.js +26 -0
- package/dist/services/delivery-stage-entry.js +56 -0
- package/dist/services/delivery-stage-handlers.js +135 -0
- package/dist/services/delivery-stage-results.js +94 -0
- package/dist/services/descriptor-coordinator-session.js +3 -1
- package/dist/services/descriptor-run-start.js +24 -4
- package/dist/services/descriptor-stage-coordinator.js +78 -10
- package/dist/services/descriptor-stage-settlement.js +59 -14
- package/dist/services/diagnostic-repair-intake.js +123 -0
- package/dist/services/diagnostic-repair-source-intake.js +102 -0
- package/dist/services/diagnostic-report.js +30 -0
- package/dist/services/diagnostic-run-bundle.js +48 -9
- package/dist/services/diagnostic-snapshot.js +2 -2
- package/dist/services/diagnostic-stage-handlers.js +3 -2
- package/dist/services/diagnostic-stage-results.js +26 -0
- package/dist/services/diagnostic-start.js +56 -6
- package/dist/services/diagnostic-work-collection.js +3 -3
- package/dist/services/distill-document-publication.js +280 -0
- package/dist/services/distill-promotion.js +197 -0
- package/dist/services/distill-publication-custody.js +162 -0
- package/dist/services/distill-stage-handlers.js +249 -0
- package/dist/services/distill-stage-results.js +105 -0
- package/dist/services/distill-user-inputs.js +4 -0
- package/dist/services/distill-work-batch.js +42 -0
- package/dist/services/distill-work-collection.js +74 -0
- package/dist/services/distill-work-registration.js +93 -0
- package/dist/services/distill-work-results.js +98 -0
- package/dist/services/eval-snapshots.js +17 -7
- package/dist/services/external-work-launch.js +8 -2
- package/dist/services/flow-catalog.js +24 -0
- package/dist/services/frozen-engine.js +19 -2
- package/dist/services/generic-execution.js +4 -3
- package/dist/services/harness-adapter.js +2 -14
- package/dist/services/hook-memory-routing.js +1 -1
- package/dist/services/hooks.js +31 -6
- package/dist/services/hybrid-contracts.js +6 -1
- package/dist/services/installed-flow-source.js +37 -2
- package/dist/services/interactive-attached-session.js +215 -0
- package/dist/services/interactive-baseline-custody.js +54 -0
- package/dist/services/interactive-before-versions.js +148 -0
- package/dist/services/interactive-bundle-data.js +85 -0
- package/dist/services/interactive-checks.js +179 -0
- package/dist/services/interactive-execution.js +33 -0
- package/dist/services/interactive-finish-integration.js +236 -0
- package/dist/services/interactive-git-baseline.js +230 -0
- package/dist/services/interactive-git-dispatch.js +278 -0
- package/dist/services/interactive-input-delivery.js +82 -0
- package/dist/services/interactive-mutation-scope.js +231 -0
- package/dist/services/interactive-request.js +287 -0
- package/dist/services/interactive-run-bundle.js +109 -0
- package/dist/services/interactive-stage-entry.js +41 -0
- package/dist/services/interactive-stage-handlers.js +289 -0
- package/dist/services/interactive-stage-observation.js +136 -0
- package/dist/services/interactive-stage-results.js +111 -0
- package/dist/services/interactive-user-inputs.js +142 -0
- package/dist/services/interactive-work-batch.js +43 -0
- package/dist/services/interactive-work-collection.js +119 -0
- package/dist/services/interactive-work-completion.js +180 -0
- package/dist/services/interactive-work-context.js +249 -0
- package/dist/services/interactive-work-history.js +112 -0
- package/dist/services/interactive-work-registration.js +124 -0
- package/dist/services/interactive-work-results.js +115 -0
- package/dist/services/interactive-work-start.js +155 -0
- package/dist/services/lifecycle-invocations.js +250 -33
- package/dist/services/managed-daemon-binding.js +52 -1
- package/dist/services/memory-compose-handler.js +2 -22
- package/dist/services/memory-finish-integration.js +58 -17
- package/dist/services/memory-fix-finish-integration.js +129 -0
- package/dist/services/memory-fix-git-dispatch.js +139 -0
- package/dist/services/memory-fix-intake-custody.js +11 -0
- package/dist/services/memory-fix-intake-settlement.js +127 -0
- package/dist/services/memory-fix-publication.js +196 -0
- package/dist/services/memory-fix-repair-admission.js +116 -0
- package/dist/services/memory-fix-request.js +141 -0
- package/dist/services/memory-fix-run-bundle.js +83 -0
- package/dist/services/memory-fix-scope.js +78 -0
- package/dist/services/memory-fix-sources.js +85 -0
- package/dist/services/memory-fix-stage-entry.js +24 -0
- package/dist/services/memory-fix-stage-handlers.js +282 -0
- package/dist/services/memory-fix-stage-results.js +106 -0
- package/dist/services/memory-fix-workspace.js +127 -0
- package/dist/services/memory-flow-user-inputs.js +88 -0
- package/dist/services/memory-init-contexts.js +55 -5
- package/dist/services/memory-init-controller-start.js +20 -4
- package/dist/services/memory-layer.js +7 -3
- package/dist/services/memory-native-review-copy.js +27 -3
- package/dist/services/memory-repair-document-delta.js +1 -0
- package/dist/services/project-flow-source.js +195 -0
- package/dist/services/project-git-integration.js +92 -0
- package/dist/services/repair-intents.js +2 -7
- package/dist/services/retained-native-hook.js +274 -0
- package/dist/services/review-fix-handoff.js +82 -0
- package/dist/services/review-fix-intake-custody.js +17 -0
- package/dist/services/review-fix-stage-entry.js +43 -0
- package/dist/services/review-fix-stage-handlers.js +176 -0
- package/dist/services/review-fix-stage-results.js +86 -0
- package/dist/services/run-control-worker.js +42 -1
- package/dist/services/run-control.js +40 -5
- package/dist/services/run-controller-adapter.js +37 -3
- package/dist/services/run-controller-process.js +25 -4
- package/dist/services/run-controller-recovery.js +46 -6
- package/dist/services/run-controller-state.js +3 -2
- package/dist/services/run-controller.js +257 -44
- package/dist/services/run-recovery.js +31 -2
- package/dist/services/run-timeline.js +59 -0
- package/dist/services/runs.js +18 -31
- package/dist/services/sessions.js +2 -1
- package/dist/services/stage-handlers.js +55 -3
- package/dist/services/stage-lifecycle.js +4 -3
- package/dist/services/stage-pause.js +26 -9
- package/dist/services/stage-report-renderer.js +2 -6
- package/dist/services/technical-notes.js +31 -0
- package/dist/services/verification-matrix.js +1 -1
- package/dist/services/version-status.js +1 -1
- package/dist/services/vnext-code-review.js +74 -5
- package/dist/services/vnext-code.js +8 -32
- package/dist/services/vnext-fanout.js +53 -8
- package/dist/services/vnext-merge.js +10 -5
- package/dist/services/vnext-plan-review.js +1 -1
- package/dist/services/vnext-plan.js +1 -1
- package/dist/services/vnext-protocolize.js +1 -1
- package/dist/services/vnext-specify.js +10 -5
- package/dist/services/work-registry.js +255 -69
- package/dist/services/worktrees.js +7 -4
- package/dist/storage/database.js +2 -1
- package/dist/verification-components.json +82 -0
- package/package.json +3 -2
- package/tools/verification-jobs.mjs +35 -10
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
// Internal transport, not operation authorization. The RUN owner registers and
|
|
6
|
+
// confirms this wrapper's PID/group, then releases stdin after its final fence.
|
|
7
|
+
// fd3 privately carries fixed argv and only permitted environment values; these
|
|
8
|
+
// resolved values never enter policy or persisted configuration. fd4 carries raw stdout to the
|
|
9
|
+
// owner in memory. Public stdout/stderr never contain provider output or env.
|
|
10
|
+
const outputLimit = 1024 * 1024;
|
|
11
|
+
const text = value => typeof value === 'string' && value.length > 0 && value.length <= 16384 && !value.includes('\0');
|
|
12
|
+
const id = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(value);
|
|
13
|
+
const emit = value => process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
14
|
+
process.stdout.on('error', () => {}); // Observer loss is not command completion.
|
|
15
|
+
process.stderr.on('error', () => {});
|
|
16
|
+
async function bounded(stream, limit) {
|
|
17
|
+
const chunks = []; let bytes = 0;
|
|
18
|
+
for await (const chunk of stream) {
|
|
19
|
+
bytes += chunk.length;
|
|
20
|
+
if (bytes > limit) throw new Error('bounded_input');
|
|
21
|
+
chunks.push(chunk);
|
|
22
|
+
}
|
|
23
|
+
return Buffer.concat(chunks);
|
|
24
|
+
}
|
|
25
|
+
let config = null, admitted = false, started = false;
|
|
26
|
+
const result = (exitCode, signal, error, outputAvailable = true, truncated = false) => {
|
|
27
|
+
emit({ schema_id: 'dd-flow/delivery-command-result@1', operation_id: config?.operation_id ?? null,
|
|
28
|
+
step_id: config?.step_id ?? null, admitted, started, exit_code: exitCode, signal, error,
|
|
29
|
+
output_available: outputAvailable, stdout_truncated: truncated });
|
|
30
|
+
process.exitCode = error ? 1 : 0;
|
|
31
|
+
};
|
|
32
|
+
try {
|
|
33
|
+
const bytes = await bounded(fs.createReadStream('', { fd: 3, autoClose: true }), 3 * outputLimit);
|
|
34
|
+
const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
35
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
36
|
+
|| Object.keys(value).sort().join() !== 'argv,environment,operation_id,permitted_environment_variables,schema_id,step_id'
|
|
37
|
+
|| value.schema_id !== 'dd-flow/delivery-command@1' || !id(value.operation_id) || !id(value.step_id)
|
|
38
|
+
|| !Array.isArray(value.argv) || !value.argv.length || value.argv.length > 128 || !value.argv.every(text)
|
|
39
|
+
|| !Array.isArray(value.permitted_environment_variables) || value.permitted_environment_variables.length > 128
|
|
40
|
+
|| !value.permitted_environment_variables.every(name => typeof name === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(name))
|
|
41
|
+
|| new Set(value.permitted_environment_variables).size !== value.permitted_environment_variables.length
|
|
42
|
+
|| !value.environment || typeof value.environment !== 'object' || Array.isArray(value.environment)
|
|
43
|
+
|| Object.entries(value.environment).some(([name, content]) => !value.permitted_environment_variables.includes(name)
|
|
44
|
+
|| typeof content !== 'string' || content.includes('\0') || content.length > 16384)) throw new Error('invalid_configuration');
|
|
45
|
+
// Fail before readiness if the private output channel was not installed.
|
|
46
|
+
const channel = fs.fstatSync(4);
|
|
47
|
+
if (!channel.isFIFO() && !channel.isSocket()) throw new Error('private_pipe_required');
|
|
48
|
+
fs.writeSync(4, Buffer.alloc(0));
|
|
49
|
+
config = value;
|
|
50
|
+
emit({ schema_id: 'dd-flow/delivery-command-ready@1', operation_id: config.operation_id, step_id: config.step_id });
|
|
51
|
+
const admission = await bounded(process.stdin, 32);
|
|
52
|
+
if (!admission.equals(Buffer.from('admitted\n'))) result(null, null, 'admission_missing');
|
|
53
|
+
else {
|
|
54
|
+
admitted = true;
|
|
55
|
+
const environment = { ...config.environment };
|
|
56
|
+
environment.DD_FLOW_DELIVERY_OPERATION_ID = config.operation_id;
|
|
57
|
+
environment.DD_FLOW_DELIVERY_STEP_ID = config.step_id;
|
|
58
|
+
const output = fs.createWriteStream('', { fd: 4, autoClose: true });
|
|
59
|
+
let available = true, truncated = false, count = 0;
|
|
60
|
+
const flushed = new Promise(resolve => { output.once('finish', resolve); output.on('error', () => { available = false; resolve(); }); });
|
|
61
|
+
const child = spawn(config.argv[0], config.argv.slice(1), { env: environment, shell: false, windowsHide: true,
|
|
62
|
+
detached: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
63
|
+
started = Boolean(child.pid);
|
|
64
|
+
let spawnFailed = false;
|
|
65
|
+
child.once('error', () => { spawnFailed = true; });
|
|
66
|
+
child.stdout.on('error', () => { available = false; });
|
|
67
|
+
child.stderr.on('error', () => { available = false; });
|
|
68
|
+
child.stderr.resume(); // Never persist or echo authenticated stderr.
|
|
69
|
+
child.stdout.on('data', bytes => {
|
|
70
|
+
const remaining = Math.max(0, outputLimit - count);
|
|
71
|
+
if (bytes.length > remaining) truncated = true;
|
|
72
|
+
if (remaining && available) output.write(bytes.subarray(0, remaining));
|
|
73
|
+
count += bytes.length;
|
|
74
|
+
});
|
|
75
|
+
const completed = await new Promise(resolve => child.once('close', (code, signal) => resolve({ code, signal })));
|
|
76
|
+
output.end(); await flushed;
|
|
77
|
+
result(completed.code, completed.signal, spawnFailed ? 'spawn_failed' : !available ? 'private_output_unavailable'
|
|
78
|
+
: truncated ? 'output_limit_exceeded' : completed.code !== 0 || completed.signal ? 'command_failed' : null, available, truncated);
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Input may contain secrets accidentally. Never print it or an exception
|
|
82
|
+
// whose message/stack could contain an authenticated argv or raw payload.
|
|
83
|
+
result(null, null, admitted ? 'transport_failed' : 'configuration_or_admission_invalid', false);
|
|
84
|
+
}
|
|
@@ -3,8 +3,6 @@ import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { inspectDaemonOperation } from "./daemon-operations.mjs";
|
|
5
5
|
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
6
|
-
import { reportedError } from "./operation-errors.mjs";
|
|
7
|
-
import { ObservationClock } from "./observation-clock.mjs";
|
|
8
6
|
import { runtimeProcess } from "./managed-daemon.mjs";
|
|
9
7
|
import { nativeShutdownConfirmed } from "./native-children.mjs";
|
|
10
8
|
|
|
@@ -103,23 +101,6 @@ export async function assertDaemonReplaceable(root) {
|
|
|
103
101
|
}
|
|
104
102
|
}
|
|
105
103
|
|
|
106
|
-
/** Observe the original operation; never re-send its parameters. */
|
|
107
|
-
export async function recoverDriverReply(root, id, { timeoutMs = 30_000, pollMs = 250 } = {}) {
|
|
108
|
-
const clock = new ObservationClock({ timeoutMs });
|
|
109
|
-
do {
|
|
110
|
-
let receipt;
|
|
111
|
-
try { receipt = await inspectDaemonOperation(root, id); }
|
|
112
|
-
catch (error) { if (error.code !== "operation_not_found") throw error; }
|
|
113
|
-
if (receipt?.state === "completed") return receipt.result;
|
|
114
|
-
if (receipt?.state === "failed") throw reportedError(receipt.error, "provider operation failed");
|
|
115
|
-
if (clock.sample()) break;
|
|
116
|
-
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
117
|
-
} while (true);
|
|
118
|
-
throw Object.assign(new Error("original provider operation has no confirmed reply; reconcile it before starting another Turn"), {
|
|
119
|
-
code: "operation_observation_lost", details: { operation_id: id, state_dir: root }
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
104
|
/** Crash recovery barrier. Import late replies into the client ledger before
|
|
124
105
|
* any subsequent productive call, even when a new runner process is used. */
|
|
125
106
|
export async function reconcileDriverReplies(root) {
|
|
@@ -6,10 +6,12 @@ import path from "node:path";
|
|
|
6
6
|
* New child discovery may walk the directory once; heartbeats never replay it. */
|
|
7
7
|
export class DroidMetadataReader {
|
|
8
8
|
constructor(factory) { this.factory = factory; this.files = new Map(); this.cache = new Map(); }
|
|
9
|
-
async locate(id) {
|
|
9
|
+
async locate(id, onProgress = async () => {}) {
|
|
10
10
|
if (this.files.has(id)) return this.files.get(id);
|
|
11
11
|
const visit = async directory => {
|
|
12
|
-
|
|
12
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(error => { if (error.code === "ENOENT") return []; throw error; });
|
|
13
|
+
await onProgress(`directory:${createHash('sha256').update(JSON.stringify([directory, entries.map(entry => entry.name)])).digest('hex')}`);
|
|
14
|
+
for (const entry of entries) {
|
|
13
15
|
const file = path.join(directory, entry.name);
|
|
14
16
|
if (entry.isDirectory()) await visit(file);
|
|
15
17
|
else if (entry.isFile() && entry.name.endsWith(".jsonl")) this.files.set(entry.name.slice(0, -6), file);
|
|
@@ -18,22 +20,24 @@ export class DroidMetadataReader {
|
|
|
18
20
|
await visit(path.join(this.factory, "sessions"));
|
|
19
21
|
return this.files.get(id) ?? null;
|
|
20
22
|
}
|
|
21
|
-
async read(id) {
|
|
22
|
-
const file = await this.locate(id); if (!file) return null;
|
|
23
|
+
async read(id, onProgress = async () => {}) {
|
|
24
|
+
const file = await this.locate(id, onProgress); if (!file) return null;
|
|
23
25
|
const settingsFile = file.replace(/\.jsonl$/, ".settings.json");
|
|
24
26
|
const info = await stat(settingsFile).catch(error => { if (error.code === "ENOENT") return null; throw error; });
|
|
25
27
|
if (!info || info.size > 1_048_576) return null;
|
|
26
28
|
const identity = `${info.ino}:${info.mtimeMs}:${info.size}`;
|
|
27
|
-
if (this.cache.get(id)?.identity === identity) return this.cache.get(id).value;
|
|
29
|
+
if (this.cache.get(id)?.identity === identity) { await onProgress(`metadata:${id}:${identity}`); return this.cache.get(id).value; }
|
|
28
30
|
const handle = await open(file, "r"); const buffer = Buffer.alloc(65_536);
|
|
29
31
|
let header;
|
|
30
32
|
try { const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); header = JSON.parse(buffer.subarray(0, bytesRead).toString("utf8").split("\n")[0]); }
|
|
31
33
|
finally { await handle.close(); }
|
|
34
|
+
await onProgress(`header:${id}:${createHash('sha256').update(JSON.stringify(header)).digest('hex')}`);
|
|
32
35
|
if (header.type !== "session_start" || header.id !== id) throw Object.assign(new Error("native metadata Session identity differs"), { code: "session_identity_mismatch" });
|
|
33
36
|
let bytes, settings;
|
|
34
37
|
try { bytes = await readFile(settingsFile, "utf8"); settings = JSON.parse(bytes); }
|
|
35
38
|
catch (error) { if (error instanceof SyntaxError || error.code === "ENOENT") return null; throw error; }
|
|
36
39
|
const value = { header, settings, transcript_path: file, source: { path: settingsFile, sha256: createHash("sha256").update(bytes).digest("hex") } };
|
|
40
|
+
await onProgress(`metadata:${id}:${identity}`);
|
|
37
41
|
this.cache.set(id, { identity, value }); return value;
|
|
38
42
|
}
|
|
39
43
|
}
|
|
@@ -3,6 +3,7 @@ export const lifecycleRoutes: {
|
|
|
3
3
|
readonly "stage pause": "stage_pause"; readonly "stage resume": "stage_resume"; readonly "work start": "work_start";
|
|
4
4
|
readonly "work finish": "work_finish"; readonly "work fail": "work_fail"; readonly "merge apply": "merge_apply";
|
|
5
5
|
readonly "merge repair": "merge_repair"; readonly "run recovery accept": "recovery_accept";
|
|
6
|
+
readonly "delivery checks": "delivery_checks"; readonly "delivery execute": "delivery_execute"; readonly "delivery reconcile": "delivery_reconcile";
|
|
6
7
|
};
|
|
7
8
|
export type LifecycleOperation = typeof lifecycleRoutes[keyof typeof lifecycleRoutes];
|
|
8
9
|
export const lifecycleInternalOptions: readonly ["invocation-id", "hook-event-id"];
|
|
@@ -2,7 +2,8 @@ export const lifecycleRoutes = {
|
|
|
2
2
|
"session register": "session_register", "stage start": "stage_start", "stage finish": "stage_finish",
|
|
3
3
|
"stage pause": "stage_pause", "stage resume": "stage_resume", "work start": "work_start",
|
|
4
4
|
"work finish": "work_finish", "work fail": "work_fail", "merge apply": "merge_apply",
|
|
5
|
-
"merge repair": "merge_repair", "run recovery accept": "recovery_accept"
|
|
5
|
+
"merge repair": "merge_repair", "run recovery accept": "recovery_accept",
|
|
6
|
+
"delivery checks": "delivery_checks", "delivery execute": "delivery_execute", "delivery reconcile": "delivery_reconcile"
|
|
6
7
|
};
|
|
7
8
|
export const lifecycleInternalOptions = ["invocation-id", "hook-event-id"];
|
|
8
9
|
export function lifecycleOperationForArgv(argv) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export function invokeNativeHook(input: { bin: string; args: string[]; nodeBin?: string; payload: unknown; home?: string | undefined; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
|
|
2
|
+
export function isStandaloneWorkStart(command: unknown): boolean;
|
|
2
3
|
export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { deadlineAt: number; remaining(phase: string, maximum?: number): number; run<T>(action: () => T | Promise<T>, phase: string): Promise<T> };
|
|
3
4
|
export function readNativeHookInput(stream: NodeJS.ReadStream, budget: { remaining(phase: string): number }, maxBytes?: number): Promise<string>;
|
|
4
5
|
export function acceptNativeHookRequest(stateDir: string, binding: Record<string, unknown>, current?: { requests: Map<string, { binding: Record<string, unknown> }>; accepting?: Map<string, Promise<Record<string, unknown>>> } | null): Promise<Record<string, unknown>>;
|
|
5
|
-
export function writeNativeHookFailure(stateDir: string, binding: Record<string, unknown> | undefined, error: unknown
|
|
6
|
-
export function readNativeHookFailures(stateDir: string, requests: Map<string, { current: unknown; binding: Record<string, unknown> }>, current: unknown, reject: (error: Error & { code?: string; details?: Record<string, unknown> }) => void): Promise<void>;
|
|
6
|
+
export function writeNativeHookFailure(stateDir: string, binding: Record<string, unknown> | undefined, error: unknown): Promise<void>;
|
|
7
|
+
export function readNativeHookFailures(stateDir: string, requests: Map<string, { current: unknown; binding: Record<string, unknown> }>, current: unknown, reject: (error: Error & { code?: string; details?: Record<string, unknown> }) => void | Promise<void>): Promise<void>;
|
|
7
8
|
export function runBoundedCommand(input: { bin: string; args: string[]; nodeBin?: string; input?: string | null; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; timeoutCode?: string; inheritDeadline?: boolean; cleanupWithinBudget?: boolean; maintenanceProgress?: { action: string; processId: string | null; startupMs?: number; inactivityMs: number } | null; signal?: AbortSignal }): Promise<string>;
|
|
@@ -1,10 +1,43 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { link, mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
6
|
import { reportedFailure } from "./process-json.mjs";
|
|
6
7
|
import { errorRecord, reportedError } from "./operation-errors.mjs";
|
|
7
8
|
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
9
|
+
import { parseLifecycleCommand } from "./lifecycle-command.mjs";
|
|
10
|
+
|
|
11
|
+
export function isStandaloneWorkStart(command) {
|
|
12
|
+
if (typeof command !== "string") return false;
|
|
13
|
+
const parsed = parseLifecycleCommand(command);
|
|
14
|
+
return parsed.kind === "standalone" && parsed.supported !== false && !parsed.invocation.wrapped && parsed.invocation.operation === "work_start";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Record a native callback, not a grant and not a claim about its creator
|
|
18
|
+
* prompt. The CLI must match an issued command and ask the owning daemon to
|
|
19
|
+
* verify physical ancestry before its existing Work CAS can admit execution. */
|
|
20
|
+
export async function retainNativeWorkStart(stateDir, binding, observer, retained) {
|
|
21
|
+
if (!observer?.operationId || typeof observer.guard !== "function") return null;
|
|
22
|
+
observer.guard();
|
|
23
|
+
const prior = [...retained.values()].find(value => value.binding?.provider_session_id === binding.provider_session_id
|
|
24
|
+
&& value.binding.tool_call_id === binding.tool_call_id && value.binding.event === binding.event);
|
|
25
|
+
if (prior) {
|
|
26
|
+
if (prior.binding.daemon_id !== binding.daemon_id || prior.binding.root_provider_session_id !== binding.root_provider_session_id
|
|
27
|
+
|| prior.binding.native_command !== binding.native_command || prior.binding.native_cwd !== binding.native_cwd)
|
|
28
|
+
throw Object.assign(new Error("Native Work callback identity was reused with different input"), { code: "native_hook_identity_conflict" });
|
|
29
|
+
prior.guard(); return prior.binding;
|
|
30
|
+
}
|
|
31
|
+
if (!isStandaloneWorkStart(binding.native_command)) return null;
|
|
32
|
+
observer.requests ??= new Map();
|
|
33
|
+
const accepted = await acceptNativeHookRequest(stateDir, { ...binding, operation_id: observer.operationId,
|
|
34
|
+
turn_generation: observer.generation, native_creator: "unconfirmed", native_admission: "assigned_work_start",
|
|
35
|
+
native_ancestry: "pending", command_sha256: createHash("sha256").update(binding.native_command).digest("hex") }, observer);
|
|
36
|
+
observer.guard();
|
|
37
|
+
observer.requests.set(accepted.request_id, { current: observer, binding: accepted });
|
|
38
|
+
retained.set(accepted.request_id, { binding: accepted, guard: observer.guard });
|
|
39
|
+
return accepted;
|
|
40
|
+
}
|
|
8
41
|
|
|
9
42
|
/** Retain accepted native requests in the existing operation hook ledger. */
|
|
10
43
|
export async function acceptNativeHookRequest(stateDir, binding, current = null) {
|
|
@@ -25,7 +58,9 @@ export async function acceptNativeHookRequest(stateDir, binding, current = null)
|
|
|
25
58
|
const requested = JSON.parse(await readFile(path.join(directory, "requested.json"), "utf8"));
|
|
26
59
|
if (requested.operation_id !== binding.operation_id || requested.daemon_id !== binding.daemon_id || !["session.prompt", "session.create", "session.start"].includes(requested.operation)
|
|
27
60
|
|| requested.operation === "session.prompt" && requested.session_id && requested.session_id !== binding.root_provider_session_id) throw Object.assign(new Error("Native hook operation does not match its retained owner"), { code: "native_hook_unproven" });
|
|
28
|
-
|
|
61
|
+
// The retained in-memory proof must be the same JSON value the CLI reads,
|
|
62
|
+
// including optional native fields omitted by JSON serialization.
|
|
63
|
+
const receipt = JSON.parse(JSON.stringify({ ...binding, schema_id: "dd-flow/hook-request@1", request_id: randomUUID() }));
|
|
29
64
|
await writeJsonAtomic(path.join(directory, "hooks", `${receipt.request_id}.json`), receipt);
|
|
30
65
|
return receipt;
|
|
31
66
|
})();
|
|
@@ -33,11 +68,31 @@ export async function acceptNativeHookRequest(stateDir, binding, current = null)
|
|
|
33
68
|
try { return await accepted; } finally { current?.accepting.delete(key); }
|
|
34
69
|
}
|
|
35
70
|
|
|
36
|
-
export async function writeNativeHookFailure(stateDir, binding, error
|
|
71
|
+
export async function writeNativeHookFailure(stateDir, binding, error) {
|
|
37
72
|
if (!binding?.request_id || !/^[A-Za-z0-9-]{1,128}$/.test(binding.request_id)) return;
|
|
38
|
-
|
|
73
|
+
// The observation budget may already have expired. It cannot also govern
|
|
74
|
+
// the single atomic write that records why that observation failed.
|
|
75
|
+
const directory = path.join(stateDir, "hook-failures"), file = path.join(directory, `${binding.request_id}.json`);
|
|
76
|
+
const value = {
|
|
39
77
|
...errorRecord(error), request_id: binding.request_id, observed_at: new Date().toISOString(), native_hook_binding: binding,
|
|
40
|
-
}
|
|
78
|
+
};
|
|
79
|
+
await mkdir(directory, { recursive: true });
|
|
80
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
81
|
+
try {
|
|
82
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
83
|
+
try { await handle.writeFile(`${JSON.stringify(value)}\n`); await handle.sync(); }
|
|
84
|
+
finally { await handle.close(); }
|
|
85
|
+
try { await link(temporary, file); }
|
|
86
|
+
catch (cause) {
|
|
87
|
+
if (cause.code !== "EEXIST") throw cause;
|
|
88
|
+
const retained = JSON.parse(await readFile(file, "utf8"));
|
|
89
|
+
if (retained.request_id !== binding.request_id || !isDeepStrictEqual(retained.native_hook_binding, binding))
|
|
90
|
+
throw Object.assign(new Error("Native hook failure receipt conflicts with its retained binding"), { code: "native_hook_identity_conflict" });
|
|
91
|
+
return; // The first bound failure remains authoritative on retry.
|
|
92
|
+
}
|
|
93
|
+
const handleDirectory = await open(directory, "r");
|
|
94
|
+
try { await handleDirectory.sync(); } finally { await handleDirectory.close(); }
|
|
95
|
+
} finally { await unlink(temporary).catch(cause => { if (cause.code !== "ENOENT") throw cause; }); }
|
|
41
96
|
}
|
|
42
97
|
|
|
43
98
|
/** Read only requests accepted for this exact live operation, never scan a mailbox. */
|
|
@@ -54,10 +109,10 @@ export async function readNativeHookFailures(stateDir, requests, current, reject
|
|
|
54
109
|
}
|
|
55
110
|
continue;
|
|
56
111
|
}
|
|
57
|
-
requests.delete(id);
|
|
58
112
|
const error = reportedError(value, "Native hook failed");
|
|
59
113
|
error.details = { ...error.details, native_hook_binding: accepted.binding };
|
|
60
|
-
reject(error);
|
|
114
|
+
await reject(error);
|
|
115
|
+
requests.delete(id);
|
|
61
116
|
}
|
|
62
117
|
}
|
|
63
118
|
|
|
@@ -188,7 +243,7 @@ export async function runBoundedCommand({ bin, args, nodeBin = process.execPath,
|
|
|
188
243
|
const abort = () => stop(Object.assign(new Error("Bounded CLI request cancelled"), { code: "process_maintenance_cancelled" }));
|
|
189
244
|
signal?.addEventListener("abort", abort, { once: true });
|
|
190
245
|
if (signal?.aborted) abort();
|
|
191
|
-
child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk;
|
|
246
|
+
child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk; });
|
|
192
247
|
// Match the existing response bound: a legal bounded error tree may exceed
|
|
193
248
|
// 64KiB when pretty-printed, plus maintenance phases preceding the receipt.
|
|
194
249
|
child.stderr.setEncoding("utf8").on("data", chunk => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Async iteration backpressures the native pipe while its existing evidence
|
|
2
|
-
// sink drains.
|
|
2
|
+
// sink drains. The input limit applies to requests, not owned replies.
|
|
3
3
|
export const MAX_NATIVE_LINE_BYTES = 8 * 1024 * 1024;
|
|
4
4
|
export async function readNativeLines(stream, onLine, onError) {
|
|
5
5
|
let buffer = "";
|
|
@@ -10,26 +10,19 @@ export async function readNativeLines(stream, onLine, onError) {
|
|
|
10
10
|
let at;
|
|
11
11
|
while ((at = buffer.indexOf("\n")) >= 0) {
|
|
12
12
|
const line = buffer.slice(0, at); buffer = buffer.slice(at + 1);
|
|
13
|
-
assertLine(line);
|
|
14
13
|
if (line.trim()) await onLine(line);
|
|
15
14
|
}
|
|
16
|
-
assertLine(buffer);
|
|
17
15
|
}
|
|
18
|
-
if (buffer.trim())
|
|
16
|
+
if (buffer.trim()) await onLine(buffer);
|
|
19
17
|
} catch (cause) { stream.pause(); await observationFailure(cause, onError); }
|
|
20
18
|
}
|
|
21
19
|
export function assertNativeLine(line, phase = "reply") {
|
|
22
|
-
if (Buffer.byteLength(line) > MAX_NATIVE_LINE_BYTES) throw Object.assign(new Error(
|
|
20
|
+
if (phase === "request" && Buffer.byteLength(line) > MAX_NATIVE_LINE_BYTES) throw Object.assign(new Error("Native JSON request exceeds the bounded input limit"), { code: "native_request_limit", details: { reason: "native_request_limit", limit_bytes: MAX_NATIVE_LINE_BYTES, effect: "no_effect" } });
|
|
23
21
|
}
|
|
24
|
-
const assertLine = assertNativeLine;
|
|
25
22
|
export function admitNativeRequestBuffer(buffer, socket, schema) {
|
|
26
23
|
try { assertNativeLine(buffer, "request"); return true; }
|
|
27
24
|
catch (error) { socket.pause(); socket.end(`${JSON.stringify({ schema_id: schema, id: null, ok: false, error: { code: error.code, message: error.message, details: error.details } })}\n`, () => socket.destroy()); return false; }
|
|
28
25
|
}
|
|
29
|
-
export function retainNativeReplyBuffer(buffer, finish) {
|
|
30
|
-
try { assertNativeLine(buffer); return true; }
|
|
31
|
-
catch (error) { finish(error); return false; }
|
|
32
|
-
}
|
|
33
26
|
export async function streamNativeDiagnostics(stream, onChunk, onError) {
|
|
34
27
|
stream.setEncoding("utf8");
|
|
35
28
|
try { for await (const chunk of stream.iterator({ destroyOnReturn: false })) await onChunk(String(chunk)); }
|
|
@@ -3,7 +3,7 @@ import { managedFlowEnv } from './managed-flow-env.mjs';
|
|
|
3
3
|
import * as Plugin from "../vendor/opencode-plugin.mjs";
|
|
4
4
|
import { hookBudget, invokeNativeHook, writeNativeHookFailure } from "./native-hook-command.mjs";
|
|
5
5
|
import { callDaemon } from "./dd-opencode-daemon.mjs";
|
|
6
|
-
import { errorRecord } from "./operation-errors.mjs";
|
|
6
|
+
import { errorRecord, isNativeHookObservationLoss } from "./operation-errors.mjs";
|
|
7
7
|
|
|
8
8
|
/** Launch-bound server plugin; never uses a process-global 'last Session'. */
|
|
9
9
|
export function makeOpenCodePlugin(binding, invoke = invokeNativeHook, resolve = callDaemon) {
|
|
@@ -14,7 +14,21 @@ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook, resolve =
|
|
|
14
14
|
const input = event.input;
|
|
15
15
|
if (!input || typeof input !== "object" || typeof input.command !== "string") throw new Error("OpenCode shell input is unavailable");
|
|
16
16
|
const identity = await budget.run(() => resolve(binding.stateDir, "hook.resolve", { sessionId: event.sessionID,
|
|
17
|
-
payload: { phase, message_id: event.messageID, tool_call_id: event.id, input } }, budget.remaining("identity")), "identity");
|
|
17
|
+
payload: { phase, message_id: event.messageID, tool_call_id: event.id, input }, ...(binding.nativeWorkStartAdmission ? { deferWorkStart: true } : {}) }, budget.remaining("identity")), "identity");
|
|
18
|
+
if (identity.observationOnly === true) {
|
|
19
|
+
if (!binding.nativeWorkStartAdmission || identity.daemonId !== binding.daemonId || identity.sessionId !== event.sessionID
|
|
20
|
+
|| identity.messageId !== event.messageID || identity.toolCallId !== event.id || identity.command !== input.command)
|
|
21
|
+
throw Object.assign(new Error("Ordinary native callback binding differs"), { code: "native_hook_unproven" });
|
|
22
|
+
return { observationOnly: true };
|
|
23
|
+
}
|
|
24
|
+
if (phase === "before" && identity.fastAck === true) {
|
|
25
|
+
const proof = identity.nativeHookBinding;
|
|
26
|
+
if (!binding.nativeWorkStartAdmission || proof?.native_admission !== "assigned_work_start" || proof.source_harness !== "opencode-server"
|
|
27
|
+
|| proof.daemon_id !== binding.daemonId || proof.provider_session_id !== event.sessionID
|
|
28
|
+
|| proof.tool_call_id !== `${event.messageID}:${event.id}` || proof.native_command !== input.command)
|
|
29
|
+
throw Object.assign(new Error("Deferred native callback binding differs"), { code: "native_hook_unproven" });
|
|
30
|
+
return { fastAck: true };
|
|
31
|
+
}
|
|
18
32
|
const info = identity?.nativeSession;
|
|
19
33
|
if (!identity?.nativeHookBinding || info?.id !== event.sessionID || !info.directory) throw Object.assign(new Error("OpenCode Session identity is unavailable"), { code: "native_hook_unproven" });
|
|
20
34
|
const cwd = path.resolve(info.directory, input.workdir ?? ".");
|
|
@@ -28,23 +42,26 @@ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook, resolve =
|
|
|
28
42
|
profile: { provider: info.model?.providerID, model: info.model?.id, variant: info.model?.variant ?? "default", agent: event.agent },
|
|
29
43
|
input: { ...input, workdir: cwd }, ...(outcome ? { outcome } : {}) };
|
|
30
44
|
};
|
|
31
|
-
const deliver = async (payload, budget) => {
|
|
45
|
+
const deliver = async (payload, budget, verified = false) => {
|
|
32
46
|
const identity = { nativeHookBinding: payload.native_hook_binding };
|
|
33
47
|
try {
|
|
34
48
|
const result = await invoke({ bin: binding.ddFlowBin, nodeBin: binding.nodeBin,
|
|
35
49
|
args: ["opencode", "event", "handle", "--project-root", binding.projectRoot, "--json"],
|
|
36
50
|
payload: { ...payload, native_hook_binding: identity.nativeHookBinding }, home: binding.ddFlowHome, timeoutMs: budget.remaining("admission") });
|
|
37
|
-
await budget.run(() => resolve(binding.stateDir, "hook.verify", { binding: identity.nativeHookBinding }, budget.remaining("generation")), "generation");
|
|
51
|
+
if (!verified) await budget.run(() => resolve(binding.stateDir, "hook.verify", { binding: identity.nativeHookBinding, retain_after: result?.observed === true }, budget.remaining("generation")), "generation");
|
|
38
52
|
if (result?.permissionDecision === "deny" || result?.hookSpecificOutput?.permissionDecision === "deny") throw Object.assign(new Error("dd-flow denied native shell admission"), { code: "native_hook_rejected" });
|
|
39
53
|
return result;
|
|
40
54
|
} catch (error) {
|
|
41
55
|
error.details = { ...error.details, native_hook_binding: identity.nativeHookBinding, ...(payload.phase === "after" ? { effect: "unknown", tool_outcome: payload.outcome } : {}) };
|
|
42
|
-
|
|
56
|
+
let storageError;
|
|
57
|
+
try { await writeNativeHookFailure(binding.stateDir, identity.nativeHookBinding, error); }
|
|
43
58
|
catch (storage) {
|
|
59
|
+
storageError = storage;
|
|
44
60
|
error.details.receipt_error = { code: storage.code ?? "hook_receipt_write_failed", message: storage.message };
|
|
45
|
-
try { await resolve(binding.stateDir, "hook.failed", { binding: identity.nativeHookBinding, failure: errorRecord(error) }, budget.remaining("failure_notification")); }
|
|
46
|
-
catch (notify) { error.details.receipt_error.notification_error = notify.code ?? "hook_notification_failed"; }
|
|
47
61
|
}
|
|
62
|
+
if (storageError?.code !== "native_hook_identity_conflict") try {
|
|
63
|
+
await resolve(binding.stateDir, "hook.failed", storageError ? { binding: identity.nativeHookBinding, failure: errorRecord(error) } : {}, storageError ? 5_000 : budget.remaining("failure_notification"));
|
|
64
|
+
} catch (notify) { if (storageError) error.details.receipt_error.notification_error = notify.code ?? "hook_notification_failed"; }
|
|
48
65
|
throw error;
|
|
49
66
|
}
|
|
50
67
|
};
|
|
@@ -57,6 +74,7 @@ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook, resolve =
|
|
|
57
74
|
if (event.input.timeout === undefined) event.input.timeout = 0;
|
|
58
75
|
if (!binding.ddFlowBin) return;
|
|
59
76
|
const budget = hookBudget(), payload = await envelope("before", event, budget);
|
|
77
|
+
if (payload.fastAck === true || payload.observationOnly === true) return;
|
|
60
78
|
event.input = { ...event.input, workdir: payload.session.directory };
|
|
61
79
|
const result = await deliver({ ...payload, input: event.input }, budget);
|
|
62
80
|
if (result?.observed) {
|
|
@@ -75,8 +93,17 @@ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook, resolve =
|
|
|
75
93
|
const success = event.status === "completed" && output?.status !== "running" && output?.exit === 0 && !output?.timeout && !output?.signal;
|
|
76
94
|
const outcome = { status: success ? "completed" : output?.status === "running" ? "running" : "failed",
|
|
77
95
|
exit_code: output?.exit ?? null, signal: output?.signal ?? null, shell_id: output?.shellID ?? null };
|
|
78
|
-
|
|
79
|
-
|
|
96
|
+
// Use the exact admitted before delivery. A later prompt may already be
|
|
97
|
+
// active; resolving against that prompt would misattribute this result.
|
|
98
|
+
try {
|
|
99
|
+
await budget.run(() => resolve(binding.stateDir, "hook.verify", { binding: original.native_hook_binding, phase: "after" }, budget.remaining("generation")), "generation");
|
|
100
|
+
await deliver({ ...original, phase: "after", outcome }, budget, true);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (!isNativeHookObservationLoss(error)) throw error;
|
|
103
|
+
// An observation timeout is not the already-finished tool's outcome.
|
|
104
|
+
process.stderr.write(`${JSON.stringify({ kind: "native_hook_observation_unavailable", code: error.code, event_id: original.event_id })}\n`);
|
|
105
|
+
return; // Retain the original binding for a later exact observation.
|
|
106
|
+
}
|
|
80
107
|
participating.delete(key(event));
|
|
81
108
|
await ctx.storage.remove(key(event));
|
|
82
109
|
});
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
// An observer losing its response does not establish that a provider Turn failed.
|
|
2
2
|
export const OPERATION_ERROR_CONTRACT_VERSION = "operation-errors@2";
|
|
3
|
+
// A lost hook response does not prove that the issued CLI or Turn failed.
|
|
4
|
+
// Do not downgrade a structured rejection merely because a transport wrapped it.
|
|
5
|
+
export function isNativeHookObservationLoss(error) {
|
|
6
|
+
if (!["native_hook_timeout", "native_hook_failed"].includes(error?.code)) return false;
|
|
7
|
+
const details = error?.details;
|
|
8
|
+
const transport = code => ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "EPIPE", "native_hook_timeout", "native_hook_failed"].includes(code) || isObservationLoss({ code });
|
|
9
|
+
if (details?.decision === "deny" || details?.permissionDecision === "deny"
|
|
10
|
+
|| details?.hookSpecificOutput?.permissionDecision === "deny" || details?.lifecycle_outcome?.effect === "no_effect"
|
|
11
|
+
|| details?.disposition === "pre_cli_shell_refusal"
|
|
12
|
+
|| typeof details?.primary_error?.code === "string" && !transport(details.primary_error.code)
|
|
13
|
+
|| typeof error?.cause?.code === "string" && !transport(error.cause.code)) return false;
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
3
16
|
export function redactDiagnostic(value) {
|
|
4
17
|
return value.replace(/\b(Bearer|Basic)\s+[^\s"']+/gi, "$1 <redacted>")
|
|
5
18
|
.replace(/(^|[^\w-])([\w-]+)(["']?\s*[=:]\s*["']?)([^\s"',;&}]+)/g,
|
|
@@ -8,7 +8,7 @@ import { writeJsonAtomic } from './runner-events.mjs';
|
|
|
8
8
|
export const OPERATION_PROGRESS_CONTRACT = 'dd-flow/operation-progress@1';
|
|
9
9
|
const context = new AsyncLocalStorage(), flights = new Map();
|
|
10
10
|
const fields = ['operation_id', 'daemon_id', 'operation', 'params_sha256', 'owner_id', 'generation'];
|
|
11
|
-
const phases = new Set(['accepted','ownership_confirmed','admission_confirmed','native_dispatched','native_progress','native_outcome_retained','settlement_started','settlement_completed','settlement_pending']);
|
|
11
|
+
const phases = new Set(['accepted','ownership_confirmed','admission_confirmed','native_dispatched','native_progress','verification_progress','native_outcome_retained','settlement_started','settlement_completed','settlement_pending']);
|
|
12
12
|
export function operationDirectory(root,id) {
|
|
13
13
|
if (typeof root!=='string' || !path.isAbsolute(root) || typeof id!=='string' || !id || id.length>1_024) throw Object.assign(new Error('invalid operation observation binding'),{code:'operation_progress_invalid'});
|
|
14
14
|
return path.join(root, 'operations', createHash('sha256').update(id).digest('hex'));
|
|
@@ -43,6 +43,7 @@ export async function readOperationProgress(root, id, expected = {}) {
|
|
|
43
43
|
|| ['daemon_id','owner_id'].some(field=>progress[field]!==null && (typeof progress[field]!=='string' || !progress[field] || progress[field].length>1_024))
|
|
44
44
|
|| progress.generation!==null && (!Number.isSafeInteger(progress.generation) || progress.generation<0)
|
|
45
45
|
|| !Number.isSafeInteger(progress.sequence) || progress.sequence < 1 || !phases.has(progress.phase)
|
|
46
|
+
|| progress.phase === 'verification_progress' && (typeof progress.native_request_id !== 'string' || !progress.native_request_id || progress.native_request_id.length > 256)
|
|
46
47
|
|| !Number.isFinite(Date.parse(requested.requested_at))
|
|
47
48
|
|| !Number.isFinite(Date.parse(progress.observed_at)) || Date.parse(progress.observed_at) > Date.now()
|
|
48
49
|
|| Date.parse(progress.observed_at) < Date.parse(requested.requested_at)
|
|
@@ -65,7 +66,10 @@ export function publishOperationProgress(root, id, phase, metadata = {}) {
|
|
|
65
66
|
const observedAt = metadata.observed_at ?? new Date().toISOString();
|
|
66
67
|
if (prior && Date.parse(observedAt)<Date.parse(prior.observed_at)) return prior;
|
|
67
68
|
// A later successful ownership gate is fresh liveness evidence while admission waits.
|
|
68
|
-
if (
|
|
69
|
+
if (phase === 'verification_progress' && (typeof metadata.native_request_id !== 'string' || !metadata.native_request_id || metadata.native_request_id.length > 256))
|
|
70
|
+
throw Object.assign(new Error('verification progress requires an actual checkpoint'), { code: 'operation_progress_invalid' });
|
|
71
|
+
if (prior && phase === 'verification_progress' && prior.phase === phase && prior.native_request_id === metadata.native_request_id) return prior;
|
|
72
|
+
if (prior && phase === prior.phase && !['native_progress', 'verification_progress'].includes(phase)
|
|
69
73
|
&& (phase !== 'ownership_confirmed' || Date.parse(observedAt) <= Date.parse(prior.observed_at))) return prior;
|
|
70
74
|
if (prior && phase === 'native_progress' && Date.parse(observedAt) - Date.parse(prior.observed_at) < 1_000) return prior;
|
|
71
75
|
const value = { schema_id: OPERATION_PROGRESS_CONTRACT,
|
|
@@ -52,9 +52,9 @@ async function command(bin, args, json, { cwd, env = {}, input = null, onProgres
|
|
|
52
52
|
return await new Promise((resolve, reject) => {
|
|
53
53
|
const child = spawn(target.command, target.args, { cwd, env: { ...process.env, ...env },
|
|
54
54
|
detached: process.platform !== "win32", stdio: [input === null ? "ignore" : "pipe", "pipe", "pipe"] });
|
|
55
|
-
const
|
|
55
|
+
const maxTail = 64 * 1024, maxLine = 1024 * 1024;
|
|
56
56
|
let stdout = "", stderr = "", pendingStderr = "", settled = false, primary = null, cursor = 0, droppingStructured = false;
|
|
57
|
-
let
|
|
57
|
+
let cleanupTimer = null, inputError = null, termination = null, retainedError = null, prettyError = "";
|
|
58
58
|
const phases = new Set();
|
|
59
59
|
const clock = timeoutMs === null ? null : new ObservationClock({ timeoutMs: startupMs ?? timeoutMs });
|
|
60
60
|
const observeSetup = setupObserver(env.DD_EVAL_OPERATION_ID ?? process.env.DD_EVAL_OPERATION_ID);
|
|
@@ -144,9 +144,8 @@ async function command(bin, args, json, { cwd, env = {}, input = null, onProgres
|
|
|
144
144
|
signal?.addEventListener("abort", abort, { once: true });
|
|
145
145
|
child.stdout.setEncoding("utf8").on("data", data => {
|
|
146
146
|
productive();
|
|
147
|
-
diagnostic(data, "stdout");
|
|
148
|
-
|
|
149
|
-
else stdout += data;
|
|
147
|
+
diagnostic(data, "stdout");
|
|
148
|
+
stdout += data;
|
|
150
149
|
});
|
|
151
150
|
child.stderr.setEncoding("utf8").on("data", data => {
|
|
152
151
|
diagnostic(data, "stderr"); stderr = (stderr + data).slice(-maxTail);
|
|
@@ -162,14 +162,22 @@ export function agyToolObservation(step, rootSessionId) {
|
|
|
162
162
|
/** Native AGY JSONL records have planner tool invocations, not matching live
|
|
163
163
|
* step IDs. GENERIC result text is deliberately not guessed into success. */
|
|
164
164
|
export function agyTranscriptObservations(text, sessionId, parentSessionId, source = {}) {
|
|
165
|
-
const
|
|
166
|
-
const session = identity("antigravity-cli", sessionId);
|
|
165
|
+
const reducer = createAgyTranscriptObserver(sessionId, parentSessionId, source), lines = text.split("\n");
|
|
167
166
|
for (const [line, bytes] of lines.entries()) {
|
|
168
167
|
if (!bytes.trim()) continue;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
try { reducer.add(JSON.parse(bytes), line + 1); }
|
|
169
|
+
catch { reducer.gap(line === lines.length - 1 ? "agy_transcript_partial_tail" : "agy_transcript_invalid_record"); }
|
|
170
|
+
}
|
|
171
|
+
return reducer.finish();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createAgyTranscriptObserver(sessionId, parentSessionId, source = {}) {
|
|
175
|
+
const events = []; let recognized = false;
|
|
176
|
+
const session = identity("antigravity-cli", sessionId);
|
|
177
|
+
function add(record, line) {
|
|
178
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) { events.push({ session, reason: "agy_transcript_shape_unsupported" }); return; }
|
|
179
|
+
if (record.conversation_id && record.conversation_id !== sessionId) { events.push({ session, reason: "agy_transcript_session_mismatch" }); return; }
|
|
180
|
+
if (!["SYSTEM_MESSAGE", "USER_INPUT", "PLANNER_RESPONSE", "GENERIC", "TOOL_RESPONSE", "CHECKPOINT"].includes(record.type)) { events.push({ session, reason: "agy_transcript_shape_unsupported" }); return; }
|
|
173
181
|
recognized = true;
|
|
174
182
|
if (record.tool_calls !== undefined && !Array.isArray(record.tool_calls)) events.push({ session, reason: "agy_transcript_shape_unsupported" });
|
|
175
183
|
if (record.truncated || record.truncation || typeof record.content === "string" && /\[.*truncat|<.*truncat/i.test(record.content)) events.push({ session, reason: "agy_transcript_truncated" });
|
|
@@ -177,12 +185,14 @@ export function agyTranscriptObservations(text, sessionId, parentSessionId, sour
|
|
|
177
185
|
if (!Number.isInteger(record.step_index) || !call || typeof call.name !== "string" || !call.name) { events.push({ session, reason: "agy_transcript_tool_identity_missing" }); continue; }
|
|
178
186
|
const id = call.id ?? call.tool_call_id;
|
|
179
187
|
events.push({ session, parent_session: identity("antigravity-cli", parentSessionId), epoch: "native_transcript", call_id: id ?? `${record.step_index}:${index}`, name: call.name,
|
|
180
|
-
status: "unknown", reason: "agy_transcript_tool_outcome_unproven", source: { ...source, channel: "agy.native_transcript", line
|
|
188
|
+
status: "unknown", reason: "agy_transcript_tool_outcome_unproven", source: { ...source, channel: "agy.native_transcript", line, step_index: record.step_index } });
|
|
181
189
|
}
|
|
182
190
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
191
|
+
return { add, gap: reason => events.push({ session, reason }), finish: () => {
|
|
192
|
+
if (!recognized) events.push({ session, reason: "agy_transcript_shape_unsupported" });
|
|
193
|
+
else events.push({ session, parent_session: identity("antigravity-cli", parentSessionId), source_observed: true, source: { ...source, channel: "agy.native_transcript" } });
|
|
194
|
+
return events;
|
|
195
|
+
} };
|
|
186
196
|
}
|
|
187
197
|
|
|
188
198
|
/** Read-only replay, shared by accounting and offline diagnosis. A journal
|