@deksden-com/dd-flow-cli 0.9.0-beta.151 → 0.9.0-beta.155
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 +23 -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 +273 -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 +170 -25
- package/dist/harness-runtime/lib/dd-opencode.mjs +40 -19
- 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 +1 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +10 -3
- 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 +77 -10
- package/dist/services/check-runner-proof.js +69 -1
- package/dist/services/cli-operation-classifier.js +6 -1
- package/dist/services/code-checks.js +56 -23
- package/dist/services/codex-hook-delivery.js +11 -2
- package/dist/services/controller-execution.js +4 -1
- package/dist/services/controller-fanout.js +9 -4
- 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 +214 -24
- 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 -4
- package/dist/services/retained-native-hook.js +245 -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 +12 -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 +209 -43
- 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 +9 -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 +252 -66
- package/dist/services/worktrees.js +7 -4
- package/dist/storage/database.js +2 -1
- package/dist/verification-components.json +82 -0
- package/package.json +4 -3
- package/tools/verification-jobs.mjs +342 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppError } from "../shared/errors.js";
|
|
4
|
+
import { appendAudit } from "./audit.js";
|
|
5
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
6
|
+
import { readPreparedInteractiveBeforeVersions, readPreparedInteractiveGitBaseline } from "./interactive-baseline-custody.js";
|
|
7
|
+
import { PreparedInteractiveBeforeVersions, restoreInteractiveBeforeVersions } from "./interactive-before-versions.js";
|
|
8
|
+
import { readInteractiveUserInputs } from "./interactive-user-inputs.js";
|
|
9
|
+
import { assertPreparedRunBundleSqlAuthority } from "./runs.js";
|
|
10
|
+
import { assertRunMutationAllowed } from "./run-recovery-guard.js";
|
|
11
|
+
import { readInteractiveWorkHistory, assertInteractiveCorrectionHistory } from "./interactive-work-history.js";
|
|
12
|
+
const eventType = "interactive.mutation_scope";
|
|
13
|
+
const digest = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
14
|
+
function refuse(reason) {
|
|
15
|
+
throw new AppError("interactive_mutation_scope_invalid", "Interactive scope requires delivered user input and original before-images", 2, { reason, effect: "no_effect", ...(reason === "current_scope_required" ? { phase: "prepare", recoverable: true } : {}) });
|
|
16
|
+
}
|
|
17
|
+
function pathsValid(paths) {
|
|
18
|
+
if (!Array.isArray(paths) || paths.length > 20_000 || paths.some(value => typeof value !== "string" || !value.trim())
|
|
19
|
+
|| new Set(paths).size !== paths.length || hybridDigest(paths) !== hybridDigest([...paths].sort()))
|
|
20
|
+
refuse("ordered_unique_paths_required");
|
|
21
|
+
}
|
|
22
|
+
/** Delivered-input custody only; the agent still assesses what it authorizes. */
|
|
23
|
+
export function assertInteractiveUserBasis(basis, inputs) {
|
|
24
|
+
if (!basis || Object.keys(basis).sort().join() !== "event_id,role,sha256" || !Number.isSafeInteger(basis.event_id) || basis.event_id < 0 || !digest(basis.sha256))
|
|
25
|
+
refuse("delivered_user_basis_required");
|
|
26
|
+
const delivered = basis.role === "request" ? basis.event_id === 0 && basis.sha256 === inputs.initial_request.sha256
|
|
27
|
+
: inputs.messages.some(source => ["turn", "answer", "finish"].includes(source.role) && source.role === basis.role && source.event_id === basis.event_id && source.sha256 === basis.sha256);
|
|
28
|
+
if (!delivered)
|
|
29
|
+
refuse("delivered_user_basis_required");
|
|
30
|
+
}
|
|
31
|
+
/** SQL-only custody, not semantic approval. A later user message invalidates
|
|
32
|
+
* active scope until the coordinator explicitly reassesses the full input set. */
|
|
33
|
+
export function readInteractiveMutationScope(context, scope, bundle) {
|
|
34
|
+
return context.db.readSnapshot(() => {
|
|
35
|
+
const baseline = readPreparedInteractiveGitBaseline(context, scope, bundle);
|
|
36
|
+
const initial = readPreparedInteractiveBeforeVersions(context, scope, bundle);
|
|
37
|
+
const inputs = readInteractiveUserInputs(context, scope, bundle);
|
|
38
|
+
const versions = new Map(initial.value.entries.map(item => [item.path, item]));
|
|
39
|
+
const approved = new Set(), requests = new Set(), records = [];
|
|
40
|
+
let revision = hybridDigest({ bundle: bundle.sha256, baseline: baseline.sha256, before: initial.sha256 }), total = 0;
|
|
41
|
+
const rows = context.db.all("SELECT id,payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.run_id')=? ORDER BY id LIMIT 129", [scope.projectId, eventType, scope.runId]);
|
|
42
|
+
if (rows.length > 128)
|
|
43
|
+
refuse("too_many_scope_decisions");
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
total += Buffer.byteLength(row.payload_json);
|
|
46
|
+
if (total > 128 * 1024 * 1024)
|
|
47
|
+
refuse("unbounded_scope_history");
|
|
48
|
+
let value;
|
|
49
|
+
try {
|
|
50
|
+
value = JSON.parse(row.payload_json);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return refuse("invalid_scope_custody");
|
|
54
|
+
}
|
|
55
|
+
if (!value || Object.keys(value).sort().join() !== "basis,before_sha256,before_versions,bundle_sha256,paths,previous_sha256,reason,request_id,run_id,schema_id,user_inputs_sha256"
|
|
56
|
+
|| value.schema_id !== "dd-flow/interactive-mutation-scope@1" || value.run_id !== scope.runId || value.bundle_sha256 !== bundle.sha256
|
|
57
|
+
|| typeof value.request_id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.request_id) || requests.has(value.request_id)
|
|
58
|
+
|| !digest(value.user_inputs_sha256) || typeof value.reason !== "string" || !value.reason.trim() || value.reason.length > 64 * 1024
|
|
59
|
+
|| value.previous_sha256 !== revision)
|
|
60
|
+
refuse("invalid_scope_custody");
|
|
61
|
+
pathsValid(value.paths);
|
|
62
|
+
assertInteractiveUserBasis(value.basis, inputs);
|
|
63
|
+
if (value.basis.event_id >= row.id || value.basis.role === "finish" && value.paths.some(name => !approved.has(name)))
|
|
64
|
+
refuse("scope_not_supported_by_prior_user_input");
|
|
65
|
+
const original = restoreInteractiveBeforeVersions(Buffer.from(JSON.stringify(value.before_versions)), value.before_sha256, baseline.baseline);
|
|
66
|
+
const missing = value.paths.filter(name => !versions.has(name));
|
|
67
|
+
if (hybridDigest(original.entries.map(item => item.path)) !== hybridDigest(missing))
|
|
68
|
+
refuse("before_scope_changed");
|
|
69
|
+
for (const item of original.entries)
|
|
70
|
+
versions.set(item.path, item);
|
|
71
|
+
for (const name of value.paths) {
|
|
72
|
+
if (!versions.has(name))
|
|
73
|
+
refuse("before_version_required");
|
|
74
|
+
approved.add(name);
|
|
75
|
+
}
|
|
76
|
+
requests.add(value.request_id);
|
|
77
|
+
records.push(value);
|
|
78
|
+
revision = hybridDigest({ previous: revision, record: value });
|
|
79
|
+
}
|
|
80
|
+
const current = records.at(-1) ?? null, needsRefresh = !current || current.user_inputs_sha256 !== inputs.sha256;
|
|
81
|
+
return { baseline: baseline.baseline, inputs, records, revision, before_versions: [...versions.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
|
|
82
|
+
ever_approved_paths: [...approved].sort(), active_paths: needsRefresh ? [] : [...current.paths], needs_scope_refresh: needsRefresh };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** A coordinator's bounded interpretation of actual user input, not an agent
|
|
86
|
+
* replacement spec or proof of applicability. Dispatch, current file state,
|
|
87
|
+
* hardening and independent review still belong to ordinary Stage/Work owners. */
|
|
88
|
+
export class PreparedInteractiveMutationScope {
|
|
89
|
+
#scope;
|
|
90
|
+
#bundle;
|
|
91
|
+
#record;
|
|
92
|
+
#before;
|
|
93
|
+
constructor(scope, bundle, record, before) {
|
|
94
|
+
this.#scope = { ...scope };
|
|
95
|
+
this.#bundle = bundle;
|
|
96
|
+
this.#record = structuredClone(record);
|
|
97
|
+
this.#before = before;
|
|
98
|
+
Object.freeze(this);
|
|
99
|
+
}
|
|
100
|
+
static prepare(context, scope, bundle, proposal) {
|
|
101
|
+
if (!proposal || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(proposal.requestId) || !digest(proposal.expectedInputsSha256)
|
|
102
|
+
|| typeof proposal.reason !== "string" || !proposal.reason.trim() || proposal.reason.length > 64 * 1024)
|
|
103
|
+
refuse("invalid_scope_proposal");
|
|
104
|
+
pathsValid(proposal.paths);
|
|
105
|
+
const current = readInteractiveMutationScope(context, scope, bundle), prior = current.records.find(item => item.request_id === proposal.requestId);
|
|
106
|
+
if (prior) {
|
|
107
|
+
if (prior.user_inputs_sha256 !== proposal.expectedInputsSha256 || hybridDigest(prior.paths) !== hybridDigest(proposal.paths)
|
|
108
|
+
|| hybridDigest(prior.basis) !== hybridDigest(proposal.basis) || prior.reason !== proposal.reason)
|
|
109
|
+
refuse("scope_request_conflict");
|
|
110
|
+
return new PreparedInteractiveMutationScope(scope, bundle, prior, null);
|
|
111
|
+
}
|
|
112
|
+
if (proposal.expectedInputsSha256 !== current.inputs.sha256)
|
|
113
|
+
refuse("user_inputs_changed");
|
|
114
|
+
assertInteractiveUserBasis(proposal.basis, current.inputs);
|
|
115
|
+
const known = new Set(current.before_versions.map(item => item.path));
|
|
116
|
+
const before = PreparedInteractiveBeforeVersions.capture(current.baseline, proposal.paths.filter(name => !known.has(name)));
|
|
117
|
+
const record = { schema_id: "dd-flow/interactive-mutation-scope@1", run_id: scope.runId, bundle_sha256: bundle.sha256,
|
|
118
|
+
request_id: proposal.requestId, user_inputs_sha256: proposal.expectedInputsSha256, basis: structuredClone(proposal.basis), reason: proposal.reason,
|
|
119
|
+
paths: [...proposal.paths], before_versions: before.snapshot(), before_sha256: before.sha256, previous_sha256: current.revision };
|
|
120
|
+
return new PreparedInteractiveMutationScope(scope, bundle, record, before);
|
|
121
|
+
}
|
|
122
|
+
snapshot() { return structuredClone(this.#record); }
|
|
123
|
+
revalidate() { this.#before?.revalidate(); }
|
|
124
|
+
retain(context) {
|
|
125
|
+
this.revalidate();
|
|
126
|
+
return context.db.writeTransaction(() => this.retainPreparedSql(context), "interactive.retainMutationScope");
|
|
127
|
+
}
|
|
128
|
+
/** Pair with an actual Stage/Work acceptance writer after outside-writer
|
|
129
|
+
* physical revalidation. No filesystem observation inside this method. */
|
|
130
|
+
retainPreparedSql(context) {
|
|
131
|
+
if (!context.db.isOwnedWriteTransaction?.())
|
|
132
|
+
refuse("owned_writer_required");
|
|
133
|
+
const current = readInteractiveMutationScope(context, this.#scope, this.#bundle), value = this.#record;
|
|
134
|
+
const prior = current.records.find(item => item.request_id === value.request_id);
|
|
135
|
+
if (prior) {
|
|
136
|
+
if (hybridDigest(prior) !== hybridDigest(value))
|
|
137
|
+
refuse("scope_request_conflict");
|
|
138
|
+
return { changed: false, scope: current };
|
|
139
|
+
}
|
|
140
|
+
assertRunMutationAllowed(context, this.#scope.projectId, this.#scope.runId);
|
|
141
|
+
if (current.revision !== value.previous_sha256 || current.inputs.sha256 !== value.user_inputs_sha256)
|
|
142
|
+
refuse("scope_changed_before_acceptance");
|
|
143
|
+
const run = assertPreparedRunBundleSqlAuthority(context, this.#scope, this.#bundle), index = JSON.parse(run.index_json);
|
|
144
|
+
const stage = index.stage_runs?.filter((item) => item.stage === index.current_stage).at(-1);
|
|
145
|
+
if (index.current_stage === "review")
|
|
146
|
+
assertInteractiveCorrectionHistory(readInteractiveWorkHistory(context, this.#scope, this.#bundle), stage?.attempt ?? "try-001");
|
|
147
|
+
if (!["running", "waiting_for_user"].includes(run.status) || !["preflight", "interactive", "consolidation", "hardening", "review"].includes(index.current_stage)
|
|
148
|
+
|| !stage || !["running", "waiting_for_user", "paused"].includes(stage.status))
|
|
149
|
+
refuse("scope_stage_required");
|
|
150
|
+
if (current.inputs.finish_requested) {
|
|
151
|
+
// Hardening may add a file needed by the original delivered task without
|
|
152
|
+
// re-asking permission. Applicability is agent-owned and reviewed against
|
|
153
|
+
// the full current inputs; the finish action alone adds no new scope.
|
|
154
|
+
if (value.basis.role === "finish" && value.paths.some(name => !current.ever_approved_paths.includes(name)))
|
|
155
|
+
refuse("finish_does_not_expand_scope");
|
|
156
|
+
}
|
|
157
|
+
else if (!["preflight", "interactive"].includes(index.current_stage) || value.basis.role === "finish")
|
|
158
|
+
refuse("scope_stage_required");
|
|
159
|
+
appendAudit(context, { projectId: this.#scope.projectId, eventType, payload: value });
|
|
160
|
+
return { changed: true, scope: readInteractiveMutationScope(context, this.#scope, this.#bundle) };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Physical working-tree evidence before integration, not semantic approval or
|
|
164
|
+
* permission to stage/commit. Observe outside the writer, then fence the same
|
|
165
|
+
* user/scope revision inside the ordinary Stage/Work acceptance transaction. */
|
|
166
|
+
export class PreparedInteractiveWorkspaceChanges {
|
|
167
|
+
sha256;
|
|
168
|
+
#scope;
|
|
169
|
+
#bundle;
|
|
170
|
+
#selection;
|
|
171
|
+
#input;
|
|
172
|
+
#observed;
|
|
173
|
+
#retainedPaths;
|
|
174
|
+
constructor(scope, bundle, selection, input, retainedPaths) {
|
|
175
|
+
if (selection.needs_scope_refresh)
|
|
176
|
+
refuse("current_scope_required");
|
|
177
|
+
this.#scope = { ...scope };
|
|
178
|
+
this.#bundle = bundle;
|
|
179
|
+
this.#selection = selection;
|
|
180
|
+
this.#input = { ...input, env: { ...input.env } };
|
|
181
|
+
this.#retainedPaths = [...retainedPaths];
|
|
182
|
+
// Retain explicitly selected paths even when project ignore rules hide new
|
|
183
|
+
// files. Git visibility alone must not turn an approved edit into no delta.
|
|
184
|
+
this.#observed = selection.baseline.observeChanges(this.#input, [...selection.active_paths, ...this.#retainedPaths]);
|
|
185
|
+
const { delta } = this.#observed, allowed = new Set(selection.active_paths);
|
|
186
|
+
const originals = new Map(selection.before_versions.map(item => [item.path, item]));
|
|
187
|
+
if (delta.head_changed || delta.index_changed)
|
|
188
|
+
refuse("git_movement_requires_integration");
|
|
189
|
+
for (const change of delta.changes) {
|
|
190
|
+
if (!allowed.has(change.path))
|
|
191
|
+
refuse("working_change_outside_scope");
|
|
192
|
+
const original = originals.get(change.path);
|
|
193
|
+
if (!original || (change.before ? original.kind !== "file" || original.sha256 !== change.before.sha256 || original.mode !== change.before.mode
|
|
194
|
+
: original.kind !== "missing"))
|
|
195
|
+
refuse("working_change_before_version_missing");
|
|
196
|
+
if (change.after && change.after.kind !== "file")
|
|
197
|
+
refuse("regular_file_scope_required");
|
|
198
|
+
if (change.after && fs.lstatSync(path.join(this.#input.workspace, change.path)).nlink !== 1)
|
|
199
|
+
refuse("linked_working_change");
|
|
200
|
+
}
|
|
201
|
+
this.sha256 = hybridDigest(this.snapshot());
|
|
202
|
+
Object.freeze(this);
|
|
203
|
+
}
|
|
204
|
+
static prepare(context, scope, bundle, input, retainedPaths = []) {
|
|
205
|
+
if (context.db.isOwnedWriteTransaction?.())
|
|
206
|
+
refuse("physical_observation_outside_writer_required");
|
|
207
|
+
return new PreparedInteractiveWorkspaceChanges(scope, bundle, readInteractiveMutationScope(context, scope, bundle), input, retainedPaths);
|
|
208
|
+
}
|
|
209
|
+
snapshot() {
|
|
210
|
+
return { schema_id: "dd-flow/interactive-workspace-changes@1", run_id: this.#scope.runId,
|
|
211
|
+
bundle_sha256: this.#bundle.sha256, scope_revision: this.#selection.revision,
|
|
212
|
+
user_inputs_sha256: this.#selection.inputs.sha256, delta: structuredClone(this.#observed.delta) };
|
|
213
|
+
}
|
|
214
|
+
currentSnapshot() { return this.#observed.current.snapshot(); }
|
|
215
|
+
revalidate() {
|
|
216
|
+
const observed = this.#selection.baseline.observeChanges(this.#input, [...this.#selection.active_paths, ...this.#retainedPaths]);
|
|
217
|
+
if (observed.current.sha256 !== this.#observed.current.sha256)
|
|
218
|
+
refuse("working_changes_changed_before_acceptance");
|
|
219
|
+
for (const change of observed.delta.changes)
|
|
220
|
+
if (change.after && fs.lstatSync(path.join(this.#input.workspace, change.path)).nlink !== 1)
|
|
221
|
+
refuse("linked_working_change");
|
|
222
|
+
}
|
|
223
|
+
assertCustody(context) {
|
|
224
|
+
if (!context.db.isOwnedWriteTransaction?.())
|
|
225
|
+
refuse("owned_writer_required");
|
|
226
|
+
const current = readInteractiveMutationScope(context, this.#scope, this.#bundle);
|
|
227
|
+
if (current.needs_scope_refresh || current.revision !== this.#selection.revision
|
|
228
|
+
|| current.inputs.sha256 !== this.#selection.inputs.sha256 || current.baseline.sha256 !== this.#selection.baseline.sha256)
|
|
229
|
+
refuse("working_changes_scope_changed");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
4
|
+
import { appendAudit } from "./audit.js";
|
|
5
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
6
|
+
import { prepareInteractiveRunBundle } from "./interactive-run-bundle.js";
|
|
7
|
+
import { PreparedInteractiveAttachedSession } from "./interactive-attached-session.js";
|
|
8
|
+
import { RetainedExecutionBundle } from "./execution-bundles.js";
|
|
9
|
+
import { runBundleAuthorityMetadata } from "./runs.js";
|
|
10
|
+
import { readRunStartRequest, runStartPayloadLimit } from "./run-start-custody.js";
|
|
11
|
+
import { nextDescriptorRunId, PreparedDescriptorRunStart } from "./descriptor-run-start.js";
|
|
12
|
+
import { bundledHandlerContext } from "./stage-handlers.js";
|
|
13
|
+
import { PreparedProjectFlowSource } from "./project-flow-source.js";
|
|
14
|
+
import { readInteractiveBundleData } from "./interactive-bundle-data.js";
|
|
15
|
+
import { PreparedInteractiveGitBaseline } from "./interactive-git-baseline.js";
|
|
16
|
+
import { PreparedInteractiveBeforeVersions, restoreInteractiveBeforeVersions } from "./interactive-before-versions.js";
|
|
17
|
+
import { observeMemoryGitPolicySelection } from "./memory-init-git-policy.js";
|
|
18
|
+
import { readBoundedReleaseFile } from "./release-source.js";
|
|
19
|
+
const event = "interactive.request", hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
20
|
+
const digest = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
21
|
+
function refuse(reason) {
|
|
22
|
+
throw new AppError("interactive_request_invalid", "Interactive requires the original retained request and its own RUN", 2, { reason, effect: "no_effect" });
|
|
23
|
+
}
|
|
24
|
+
function selection(input) {
|
|
25
|
+
return hybridDigest({ source: input.source.sha256, execution: input.execution.sha256, engine: input.engine.manifestSha256,
|
|
26
|
+
baseline: input.baseline.sha256, before: input.beforeVersions.sha256, request: input.requestText,
|
|
27
|
+
git: input.gitExecutable, policy: input.gitPolicy, receipts: [...input.receipts ?? []],
|
|
28
|
+
...(input.launchSelection !== undefined ? { launch_selection: input.launchSelection } : {}),
|
|
29
|
+
receipt_bytes: [...input.receiptBytes ?? []].map(([key, bytes]) => [key, Buffer.from(bytes).toString("base64")]) });
|
|
30
|
+
}
|
|
31
|
+
/** Allocation can choose a different free RUN ID without changing any selected
|
|
32
|
+
* task data. Only the engine binding's RUN field is allocation-local. */
|
|
33
|
+
function bundleSelection(manifest, scope) {
|
|
34
|
+
return hybridDigest({ flow_id: manifest.flow_id, scope, descriptor: manifest.descriptor,
|
|
35
|
+
execution_settings: manifest.execution_settings, profile_settings: manifest.profile_settings,
|
|
36
|
+
engine_manifest: manifest.engine_manifest, engine_binding_path: manifest.engine_binding.path,
|
|
37
|
+
canon: manifest.canon, project_pack: manifest.project_pack, bootstrap_receipts: manifest.bootstrap_receipts,
|
|
38
|
+
inventory: manifest.inventory.filter(item => item.path !== manifest.engine_binding.path) });
|
|
39
|
+
}
|
|
40
|
+
function decode(value, limit) {
|
|
41
|
+
if (typeof value !== "string" || value.length > Math.ceil(limit / 3) * 4)
|
|
42
|
+
refuse("invalid_request_bytes");
|
|
43
|
+
const bytes = Buffer.from(value, "base64");
|
|
44
|
+
if (!bytes.length || bytes.length > limit || bytes.toString("base64") !== value)
|
|
45
|
+
refuse("invalid_request_bytes");
|
|
46
|
+
return bytes;
|
|
47
|
+
}
|
|
48
|
+
function json(bytes) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return refuse("invalid_request_json");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** SQL/data only, including when called by the allocation writer. Full engine,
|
|
57
|
+
* compiled schema and physical workspace admission happens outside the writer. */
|
|
58
|
+
export function readInteractiveRequest(context, { projectRoot, requestId }) {
|
|
59
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$/.test(requestId))
|
|
60
|
+
refuse("invalid_request_id");
|
|
61
|
+
const scope = { projectRoot, requestId }, project = context.db.get("SELECT id FROM projects WHERE root=? AND status='active'", [projectRoot]);
|
|
62
|
+
if (!project)
|
|
63
|
+
refuse("project_not_registered");
|
|
64
|
+
const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.scope.requestId')=? LIMIT 2", [project.id, event, requestId]);
|
|
65
|
+
if (!rows.length)
|
|
66
|
+
return null;
|
|
67
|
+
if (rows.length !== 1 || Buffer.byteLength(rows[0].payload_json) > runStartPayloadLimit)
|
|
68
|
+
refuse("ambiguous_or_unbounded_request");
|
|
69
|
+
const value = json(Buffer.from(rows[0].payload_json));
|
|
70
|
+
if (!value || Object.keys(value).sort().join() !== "admission_json,admission_sha256,blobs,bound_at,bundle_selection_sha256,manifest_base64,manifest_sha256,project_id,schema_id,scope,selection_sha256,source_sha256"
|
|
71
|
+
|| value.schema_id !== "dd-flow/interactive-request@1" || value.project_id !== project.id || hybridDigest(value.scope) !== hybridDigest(scope)
|
|
72
|
+
|| ![value.selection_sha256, value.bundle_selection_sha256, value.source_sha256, value.manifest_sha256, value.admission_sha256].every(digest)
|
|
73
|
+
|| typeof value.bound_at !== "string" || !Number.isFinite(Date.parse(value.bound_at))
|
|
74
|
+
|| typeof value.admission_json !== "string" || Buffer.byteLength(value.admission_json) > 1024 * 1024
|
|
75
|
+
|| !Array.isArray(value.blobs) || value.blobs.length > 4096)
|
|
76
|
+
refuse("request_scope_changed");
|
|
77
|
+
const bytes = decode(value.manifest_base64, 1024 * 1024), manifest = json(bytes);
|
|
78
|
+
const admission = json(Buffer.from(value.admission_json));
|
|
79
|
+
if (hash(bytes) !== value.manifest_sha256 || hybridDigest(admission) !== value.admission_sha256
|
|
80
|
+
|| !manifest || manifest.schema_id !== "dd-flow/execution-bundle@1" || manifest.flow_id !== "interactive"
|
|
81
|
+
|| !Array.isArray(manifest.inventory) || manifest.inventory.length !== value.blobs.length
|
|
82
|
+
|| manifest.inventory.some(item => !item || typeof item.path !== "string" || !digest(item.sha256))
|
|
83
|
+
|| typeof manifest.engine_binding?.path !== "string"
|
|
84
|
+
|| !Array.isArray(manifest.bootstrap_receipts) || manifest.bootstrap_receipts.length
|
|
85
|
+
|| !admission?.executionScope || admission.executionScope.projectRoot !== projectRoot || admission.executionScope.requestId !== requestId
|
|
86
|
+
|| bundleSelection(manifest, admission.executionScope) !== value.bundle_selection_sha256)
|
|
87
|
+
refuse("retained_bundle_changed");
|
|
88
|
+
const refs = new Map(manifest.inventory.map(item => [item.path, item.sha256])), names = new Set();
|
|
89
|
+
let total = 0;
|
|
90
|
+
for (const item of value.blobs) {
|
|
91
|
+
if (!item || Object.keys(item).sort().join() !== "base64,path" || typeof item.path !== "string" || names.has(item.path))
|
|
92
|
+
refuse("retained_blob_inventory_changed");
|
|
93
|
+
const content = decode(item.base64, 1024 * 1024);
|
|
94
|
+
total += content.length;
|
|
95
|
+
if (total > 32 * 1024 * 1024 || hash(content) !== refs.get(item.path))
|
|
96
|
+
refuse("retained_blob_changed");
|
|
97
|
+
names.add(item.path);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
/** Compare supplied launch selectors with retained raw intent, not today's
|
|
102
|
+
* aliases/defaults or a newly observed equivalent model. Omission preserves the
|
|
103
|
+
* original selection. This reads only the independently validated request. */
|
|
104
|
+
export function assertInteractiveLaunchReplay(context, scope, supplied) {
|
|
105
|
+
const request = readInteractiveRequest(context, scope);
|
|
106
|
+
if (!request)
|
|
107
|
+
refuse("original_request_required");
|
|
108
|
+
const blob = (name) => {
|
|
109
|
+
const selected = request.blobs.find(item => item.path === name);
|
|
110
|
+
if (!selected)
|
|
111
|
+
refuse("original_launch_selection_required");
|
|
112
|
+
return Buffer.from(selected.base64, "base64");
|
|
113
|
+
};
|
|
114
|
+
const admission = json(Buffer.from(request.admission_json));
|
|
115
|
+
if (supplied.workspace !== undefined && supplied.workspace !== admission.executionScope.workspace
|
|
116
|
+
|| supplied.requestText !== undefined && !Buffer.from(supplied.requestText).equals(blob("runtime/source-request.md")))
|
|
117
|
+
refuse("launch_inputs_changed");
|
|
118
|
+
if (supplied.gitPolicy !== undefined) {
|
|
119
|
+
const frozen = json(blob("runtime/git-policy.json"));
|
|
120
|
+
if (hybridDigest(supplied.gitPolicy) !== hybridDigest({ route: frozen.policy.route, integrationBranch: frozen.policy.integration_branch }))
|
|
121
|
+
refuse("launch_inputs_changed");
|
|
122
|
+
}
|
|
123
|
+
const selectors = supplied.launchSelection;
|
|
124
|
+
if (selectors && Object.keys(selectors).length) {
|
|
125
|
+
const original = json(blob("runtime/launch-selection.json"));
|
|
126
|
+
if (Object.entries(selectors).some(([key, value]) => !Object.hasOwn(original, key)
|
|
127
|
+
|| hybridDigest(value) !== hybridDigest(original[key])))
|
|
128
|
+
refuse("launch_inputs_changed");
|
|
129
|
+
}
|
|
130
|
+
return request;
|
|
131
|
+
}
|
|
132
|
+
/** Called with compiled bundle admission already complete. No filesystem or
|
|
133
|
+
* handler/schema loading: this fence also protects the allocation transaction. */
|
|
134
|
+
export function assertInteractiveRequestBundle(context, projectId, bundle) {
|
|
135
|
+
const frozen = bundle.execution.snapshot(), request = readInteractiveRequest(context, frozen.scope);
|
|
136
|
+
if (!request || request.project_id !== projectId || bundle.manifest.flow_id !== "interactive"
|
|
137
|
+
|| bundleSelection(bundle.manifest, frozen.scope) !== request.bundle_selection_sha256)
|
|
138
|
+
refuse("request_bundle_changed");
|
|
139
|
+
const binding = json(bundle.blob(bundle.manifest.engine_binding.path));
|
|
140
|
+
const original = request.blobs.find(item => item.path === bundle.manifest.engine_binding.path);
|
|
141
|
+
const initial = original ? json(Buffer.from(original.base64, "base64")) : null;
|
|
142
|
+
if (!initial || binding.run_id !== bundle.manifest.run_id || binding.bound_at !== request.bound_at
|
|
143
|
+
|| hybridDigest({ ...binding, run_id: null }) !== hybridDigest({ ...initial, run_id: null }))
|
|
144
|
+
refuse("allocation_binding_changed");
|
|
145
|
+
}
|
|
146
|
+
/** Freeze once before allocation. Attached hook claim and request insertion
|
|
147
|
+
* share one writer; replay never creates another claim, RUN or input capture. */
|
|
148
|
+
export function retainInteractiveRequest(context, input, attached) {
|
|
149
|
+
if (context.db.isOwnedWriteTransaction?.())
|
|
150
|
+
refuse("preparation_outside_writer_required");
|
|
151
|
+
const frozen = input.execution.snapshot(), scope = { projectRoot: frozen.scope.projectRoot, requestId: frozen.scope.requestId };
|
|
152
|
+
const project = requireProjectByRoot(context, scope.projectRoot), chosen = selection(input), prior = readInteractiveRequest(context, scope);
|
|
153
|
+
if (prior) {
|
|
154
|
+
if (prior.selection_sha256 !== chosen)
|
|
155
|
+
refuse("request_changed");
|
|
156
|
+
return prior;
|
|
157
|
+
}
|
|
158
|
+
if (readRunStartRequest(context, project.id, scope.requestId))
|
|
159
|
+
refuse("foreign_run_request");
|
|
160
|
+
if (frozen.settings.execution === "attached") {
|
|
161
|
+
if (!(attached instanceof PreparedInteractiveAttachedSession) || hybridDigest(attached.snapshot().scope) !== hybridDigest(frozen.scope)
|
|
162
|
+
|| hybridDigest(attached.evidence().reference) !== hybridDigest(frozen.settings.attached_session_receipt))
|
|
163
|
+
refuse("trusted_attached_selection_required");
|
|
164
|
+
}
|
|
165
|
+
else if (attached)
|
|
166
|
+
refuse("foreign_attached_selection");
|
|
167
|
+
const bundle = prepareInteractiveRunBundle(context, input), metadata = runBundleAuthorityMetadata(bundle);
|
|
168
|
+
const value = { schema_id: "dd-flow/interactive-request@1", project_id: project.id, scope,
|
|
169
|
+
selection_sha256: chosen, bundle_selection_sha256: bundleSelection(bundle.manifest, frozen.scope), source_sha256: input.source.sha256,
|
|
170
|
+
bound_at: input.boundAt, manifest_base64: bundle.manifestBytes().toString("base64"), manifest_sha256: bundle.sha256,
|
|
171
|
+
admission_json: metadata.admissionJson, admission_sha256: metadata.anchor.admission_sha256,
|
|
172
|
+
blobs: bundle.manifest.inventory.map(ref => ({ path: ref.path, base64: bundle.blob(ref.path).toString("base64") })) };
|
|
173
|
+
if (Buffer.byteLength(JSON.stringify(value)) > runStartPayloadLimit)
|
|
174
|
+
refuse("request_exceeds_bound");
|
|
175
|
+
return context.db.writeTransaction(() => {
|
|
176
|
+
const current = readInteractiveRequest(context, scope);
|
|
177
|
+
if (current) {
|
|
178
|
+
if (current.selection_sha256 !== chosen)
|
|
179
|
+
refuse("request_changed");
|
|
180
|
+
return current;
|
|
181
|
+
}
|
|
182
|
+
if (readRunStartRequest(context, project.id, scope.requestId))
|
|
183
|
+
refuse("foreign_run_request");
|
|
184
|
+
attached?.retainPreparedSql(context);
|
|
185
|
+
appendAudit(context, { projectId: project.id, eventType: event, payload: value });
|
|
186
|
+
const retained = readInteractiveRequest(context, scope);
|
|
187
|
+
if (!retained)
|
|
188
|
+
refuse("request_readback_missing");
|
|
189
|
+
return retained;
|
|
190
|
+
}, "interactive.retainRequest");
|
|
191
|
+
}
|
|
192
|
+
/** Restore initial data from request custody, never current defaults, files or
|
|
193
|
+
* global canon. Generic admission still checks actual selected engine bytes. */
|
|
194
|
+
function restoreBundle(request) {
|
|
195
|
+
const admission = json(Buffer.from(request.admission_json));
|
|
196
|
+
if (!Array.isArray(admission.receipts) || admission.receipts.some(item => !Array.isArray(item) || item.length !== 2 || !digest(item[0]))
|
|
197
|
+
|| new Set(admission.receipts.map(item => item[0])).size !== admission.receipts.length)
|
|
198
|
+
refuse("invalid_retained_execution_receipts");
|
|
199
|
+
return RetainedExecutionBundle.admit(decode(request.manifest_base64, 1024 * 1024), request.manifest_sha256, new Map(request.blobs.map(item => [item.path, Buffer.from(item.base64, "base64")])), { ...admission, receipts: new Map(admission.receipts), descriptorContext: bundledHandlerContext(new Map()) });
|
|
200
|
+
}
|
|
201
|
+
function bindRunId(bundle, runId) {
|
|
202
|
+
if (bundle.manifest.run_id === runId)
|
|
203
|
+
return bundle;
|
|
204
|
+
const manifest = structuredClone(bundle.manifest), admission = bundle.admission();
|
|
205
|
+
const binding = json(bundle.blob(manifest.engine_binding.path));
|
|
206
|
+
binding.run_id = runId;
|
|
207
|
+
const bytes = Buffer.from(JSON.stringify(binding)), sha256 = hash(bytes);
|
|
208
|
+
const blobs = new Map(manifest.inventory.map(ref => [ref.path, bundle.blob(ref.path)]));
|
|
209
|
+
blobs.set(manifest.engine_binding.path, bytes);
|
|
210
|
+
manifest.run_id = runId;
|
|
211
|
+
manifest.engine_binding.sha256 = sha256;
|
|
212
|
+
for (const ref of manifest.inventory)
|
|
213
|
+
if (ref.path === manifest.engine_binding.path)
|
|
214
|
+
ref.sha256 = sha256;
|
|
215
|
+
const encoded = Buffer.from(JSON.stringify(manifest));
|
|
216
|
+
return RetainedExecutionBundle.admit(encoded, hash(encoded), blobs, { ...admission, runId, engineBindingSha256: sha256 });
|
|
217
|
+
}
|
|
218
|
+
function revalidateBeforeAllocation(context, request, bundle) {
|
|
219
|
+
const scope = bundle.execution.snapshot().scope, manifestBytes = bundle.blob("project-pack/manifest.json");
|
|
220
|
+
const pack = json(manifestBytes);
|
|
221
|
+
if (!Array.isArray(pack.included_files))
|
|
222
|
+
refuse("invalid_retained_project_pack");
|
|
223
|
+
const source = PreparedProjectFlowSource.restoreRetained({ schema_id: "dd-flow/project-flow-source@1",
|
|
224
|
+
scope: { projectRoot: scope.projectRoot, workspace: scope.workspace, flowId: "interactive" },
|
|
225
|
+
blobs: ["project-pack/manifest.json", ...pack.included_files].map(name => ({ path: name, base64: bundle.blob(name).toString("base64") })) }, request.source_sha256);
|
|
226
|
+
if (hybridDigest(source.projectPackProvenance()) !== hybridDigest(bundle.manifest.project_pack))
|
|
227
|
+
refuse("retained_project_pack_changed");
|
|
228
|
+
bundle.engine().admitInteractiveFlow(source);
|
|
229
|
+
if (bundle.execution.settings.execution === "attached") {
|
|
230
|
+
const attached = PreparedInteractiveAttachedSession.restore(context, scope);
|
|
231
|
+
if (hybridDigest(attached.evidence().reference) !== hybridDigest(bundle.execution.settings.attached_session_receipt))
|
|
232
|
+
refuse("retained_attached_selection_changed");
|
|
233
|
+
}
|
|
234
|
+
revalidateInteractiveEntryWorkspace(context, bundle);
|
|
235
|
+
}
|
|
236
|
+
/** Shared RUN-start physical fence. Git porcelain can remain unchanged while
|
|
237
|
+
* an already-dirty file's content changes, so status alone is insufficient. */
|
|
238
|
+
export function revalidateInteractiveEntryWorkspace(context, bundle) {
|
|
239
|
+
if (context.db.isOwnedWriteTransaction?.())
|
|
240
|
+
refuse("physical_revalidation_outside_writer_required");
|
|
241
|
+
if (bundle.manifest.flow_id !== "interactive")
|
|
242
|
+
refuse("interactive_bundle_required");
|
|
243
|
+
const scope = bundle.execution.snapshot().scope;
|
|
244
|
+
const policy = json(bundle.blob("runtime/git-policy.json"));
|
|
245
|
+
if (hybridDigest(policy.scope) !== hybridDigest({ requestId: scope.requestId, projectRoot: scope.projectRoot, workspaceRoot: scope.workspace })
|
|
246
|
+
|| hash(readBoundedReleaseFile(policy.git_executable, 64 * 1024 * 1024)) !== policy.git_sha256)
|
|
247
|
+
refuse("retained_git_selection_changed");
|
|
248
|
+
const observed = observeMemoryGitPolicySelection(context.env, { scope: policy.scope, gitExecutable: policy.git_executable,
|
|
249
|
+
selection: { route: policy.policy.route, integrationBranch: policy.policy.integration_branch } });
|
|
250
|
+
if (hybridDigest(observed) !== hybridDigest(policy.policy))
|
|
251
|
+
refuse("git_policy_changed_before_allocation");
|
|
252
|
+
const retained = readInteractiveBundleData(bundle, "runtime/git-baseline.json"), before = readInteractiveBundleData(bundle, "runtime/before-versions.json");
|
|
253
|
+
const baseline = PreparedInteractiveGitBaseline.restore(retained.bytes, retained.sha256);
|
|
254
|
+
const versions = restoreInteractiveBeforeVersions(before.bytes, before.sha256, baseline);
|
|
255
|
+
// Physical equality check only. Retained before-images remain authoritative;
|
|
256
|
+
// later current bytes cannot replace the original evidence on retry.
|
|
257
|
+
const current = (() => {
|
|
258
|
+
try {
|
|
259
|
+
return PreparedInteractiveBeforeVersions.capture(baseline, versions.entries.map(item => item.path));
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (error instanceof AppError)
|
|
263
|
+
throw error;
|
|
264
|
+
return refuse("before_versions_unavailable_before_allocation");
|
|
265
|
+
}
|
|
266
|
+
})();
|
|
267
|
+
if (current.sha256 !== before.sha256)
|
|
268
|
+
refuse("before_versions_changed_before_allocation");
|
|
269
|
+
baseline.revalidate({ projectRoot: scope.projectRoot, workspace: scope.workspace, gitExecutable: policy.git_executable, env: context.env });
|
|
270
|
+
}
|
|
271
|
+
export function startInteractiveRun(context, scope) {
|
|
272
|
+
if (context.db.isOwnedWriteTransaction?.())
|
|
273
|
+
refuse("preparation_outside_writer_required");
|
|
274
|
+
const request = readInteractiveRequest(context, scope);
|
|
275
|
+
if (!request)
|
|
276
|
+
refuse("original_request_required");
|
|
277
|
+
const allocation = readRunStartRequest(context, request.project_id, scope.requestId);
|
|
278
|
+
if (allocation) {
|
|
279
|
+
if (allocation.payload.initial_index.flow_kind !== "interactive")
|
|
280
|
+
refuse("foreign_run_request");
|
|
281
|
+
return PreparedDescriptorRunStart.restore(context, scope).apply(context);
|
|
282
|
+
}
|
|
283
|
+
const bundle = bindRunId(restoreBundle(request), nextDescriptorRunId(context, { ...scope, slug: "interactive" }));
|
|
284
|
+
assertInteractiveRequestBundle(context, request.project_id, bundle);
|
|
285
|
+
revalidateBeforeAllocation(context, request, bundle);
|
|
286
|
+
return PreparedDescriptorRunStart.prepare(context, { bundle }).apply(context);
|
|
287
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { PreparedProjectFlowSource } from "./project-flow-source.js";
|
|
6
|
+
import { FrozenGenericExecution } from "./generic-execution.js";
|
|
7
|
+
import { AdmittedFrozenEngine } from "./frozen-engine.js";
|
|
8
|
+
import { RetainedExecutionBundle } from "./execution-bundles.js";
|
|
9
|
+
import { PreparedInteractiveGitBaseline } from "./interactive-git-baseline.js";
|
|
10
|
+
import { PreparedInteractiveBeforeVersions, restoreInteractiveBeforeVersions } from "./interactive-before-versions.js";
|
|
11
|
+
import { putInteractiveBundleData } from "./interactive-bundle-data.js";
|
|
12
|
+
import { observeMemoryGitPolicySelection } from "./memory-init-git-policy.js";
|
|
13
|
+
import { readBoundedReleaseFile } from "./release-source.js";
|
|
14
|
+
import { nextDescriptorRunId } from "./descriptor-run-start.js";
|
|
15
|
+
import { bundledHandlerContext } from "./stage-handlers.js";
|
|
16
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
17
|
+
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
18
|
+
const encode = (value) => Buffer.from(JSON.stringify(value));
|
|
19
|
+
function refuse(reason) {
|
|
20
|
+
throw new AppError("interactive_run_bundle_invalid", "Interactive requires its original request, project pack, workspace and execution selection", 2, { reason, effect: "no_effect" });
|
|
21
|
+
}
|
|
22
|
+
/** Initial assembly only, outside the writer. Creates no RUN, workspace or
|
|
23
|
+
* Session and grants no file/Git mutation. The request owner must retain this
|
|
24
|
+
* exact bundle before allocation; retries restore it, never recapture state. */
|
|
25
|
+
export function prepareInteractiveRunBundle(context, input) {
|
|
26
|
+
if (context.db.isOwnedWriteTransaction?.())
|
|
27
|
+
refuse("preparation_outside_writer_required");
|
|
28
|
+
if (!(input.source instanceof PreparedProjectFlowSource) || !(input.execution instanceof FrozenGenericExecution)
|
|
29
|
+
|| !(input.engine instanceof AdmittedFrozenEngine) || !(input.baseline instanceof PreparedInteractiveGitBaseline)
|
|
30
|
+
|| !(input.beforeVersions instanceof PreparedInteractiveBeforeVersions) || !Number.isFinite(Date.parse(input.boundAt)))
|
|
31
|
+
refuse("unprepared_selection");
|
|
32
|
+
if (typeof input.requestText !== "string" || !input.requestText.trim() || Buffer.byteLength(input.requestText) > 64 * 1024)
|
|
33
|
+
refuse("original_request_required");
|
|
34
|
+
const frozen = input.execution.snapshot(), flow = input.source.resolveRegisteredFlow(), sourceScope = input.source.scope();
|
|
35
|
+
if (frozen.settings.flow_id !== "interactive" || flow.descriptor.id !== "interactive" || flow.descriptor.origin !== "project"
|
|
36
|
+
|| flow.sha256 !== frozen.settings.descriptor_sha256)
|
|
37
|
+
refuse("flow_selection_changed");
|
|
38
|
+
const baseline = input.baseline.snapshot();
|
|
39
|
+
if (sourceScope.projectRoot !== frozen.scope.projectRoot || sourceScope.workspace !== frozen.scope.workspace
|
|
40
|
+
|| baseline.project_root !== frozen.scope.projectRoot || baseline.workspace !== frozen.scope.workspace)
|
|
41
|
+
refuse("workspace_selection_changed");
|
|
42
|
+
// Before-images from another baseline are not interchangeable, even when the
|
|
43
|
+
// selected paths happen to match. Empty initial images are valid before scope.
|
|
44
|
+
restoreInteractiveBeforeVersions(input.beforeVersions.bytes(), input.beforeVersions.sha256, input.baseline);
|
|
45
|
+
input.engine.admitInteractiveFlow(input.source);
|
|
46
|
+
if (!path.isAbsolute(input.gitExecutable))
|
|
47
|
+
refuse("absolute_git_executable_required");
|
|
48
|
+
const gitExecutable = fs.realpathSync(input.gitExecutable), gitSha256 = hash(readBoundedReleaseFile(gitExecutable, 64 * 1024 * 1024));
|
|
49
|
+
const gitScope = { requestId: frozen.scope.requestId, projectRoot: frozen.scope.projectRoot, workspaceRoot: frozen.scope.workspace };
|
|
50
|
+
const policy = observeMemoryGitPolicySelection(context.env, { scope: gitScope, gitExecutable, selection: input.gitPolicy });
|
|
51
|
+
if (policy.repository !== baseline.repository || policy.workspace_branch !== baseline.branch || policy.initial_head !== baseline.head)
|
|
52
|
+
refuse("git_policy_baseline_changed");
|
|
53
|
+
const manifest = input.engine.manifest(), checksum = manifest.integrity.checksum;
|
|
54
|
+
if (typeof checksum !== "string")
|
|
55
|
+
refuse("engine_identity_missing");
|
|
56
|
+
const runId = nextDescriptorRunId(context, { projectRoot: frozen.scope.projectRoot, requestId: frozen.scope.requestId, slug: "interactive" });
|
|
57
|
+
const binding = { schema_id: "dd-flow/run-engine-binding@1", run_id: runId,
|
|
58
|
+
project_root: frozen.scope.projectRoot, bound_at: input.boundAt, source: "run_creation", reason: "Request-bound interactive work with explicit same-RUN finish",
|
|
59
|
+
engine: { package_name: manifest.package_name, package_version: manifest.package_version, engine_version: manifest.engine_version,
|
|
60
|
+
integrity_checksum: checksum, snapshot_root: manifest.snapshot_root }, probe: { status: "not_required", command: null } };
|
|
61
|
+
const blobs = input.source.blobs();
|
|
62
|
+
const put = (logical, bytes) => {
|
|
63
|
+
if ([...blobs.keys()].some(name => name.toLowerCase() === logical.toLowerCase()) || !bytes.length || bytes.length > 1024 * 1024)
|
|
64
|
+
refuse("bundle_path_collision_or_size");
|
|
65
|
+
blobs.set(logical, bytes);
|
|
66
|
+
return { path: logical, sha256: hash(bytes) };
|
|
67
|
+
};
|
|
68
|
+
put("runtime/source-request.md", Buffer.from(input.requestText));
|
|
69
|
+
if (input.launchSelection !== undefined) {
|
|
70
|
+
if (!input.launchSelection || typeof input.launchSelection !== "object" || Array.isArray(input.launchSelection)
|
|
71
|
+
|| Object.keys(input.launchSelection).some(key => !["rootProfile", "stageProfiles", "workProfiles", "execution", "stageSessionMode", "delegation"].includes(key)))
|
|
72
|
+
refuse("invalid_launch_selection");
|
|
73
|
+
// Raw selectors are replay intent, not execution authority. FrozenGenericExecution
|
|
74
|
+
// remains the only owner of resolved profiles and Session/delegation settings.
|
|
75
|
+
put("runtime/launch-selection.json", encode(input.launchSelection));
|
|
76
|
+
}
|
|
77
|
+
put("runtime/git-policy.json", encode({ schema_id: "dd-flow/interactive-git-policy@1", scope: gitScope,
|
|
78
|
+
git_executable: gitExecutable, git_sha256: gitSha256, policy }));
|
|
79
|
+
for (const reference of [frozen.settings.attached_session_receipt, frozen.settings.delegation.native_capacity_receipt]) {
|
|
80
|
+
if (!reference)
|
|
81
|
+
continue;
|
|
82
|
+
const bytes = input.receiptBytes?.get(reference.sha256);
|
|
83
|
+
if (!bytes || hash(bytes) !== reference.sha256 || !input.receipts?.has(reference.sha256))
|
|
84
|
+
refuse("execution_receipt_missing");
|
|
85
|
+
put(reference.path, Buffer.from(bytes));
|
|
86
|
+
}
|
|
87
|
+
const profiles = { schema_id: "dd-flow/profile-settings@1", profiles: frozen.profiles, aliases: {}, default_profile: null };
|
|
88
|
+
const profile = put("runtime/profiles.json", encode({ ...profiles, revision: hybridDigest(profiles) }));
|
|
89
|
+
const execution = put("runtime/execution.json", encode(frozen.settings));
|
|
90
|
+
const engineBinding = put("runtime/engine-binding.json", encode(binding));
|
|
91
|
+
const engineManifest = put("runtime/engine-manifest.json", input.engine.manifestBytes());
|
|
92
|
+
putInteractiveBundleData(blobs, "runtime/git-baseline.json", input.baseline.bytes());
|
|
93
|
+
putInteractiveBundleData(blobs, "runtime/before-versions.json", input.beforeVersions.bytes());
|
|
94
|
+
const provenance = { canon: input.source.canonProvenance(), project_pack: input.source.projectPackProvenance() };
|
|
95
|
+
const bundle = { schema_id: "dd-flow/execution-bundle@1", run_id: runId, flow_id: "interactive",
|
|
96
|
+
descriptor: input.source.descriptorReference(), execution_settings: execution, profile_settings: profile,
|
|
97
|
+
engine_binding: engineBinding, engine_manifest: engineManifest, bootstrap_receipts: [], ...provenance,
|
|
98
|
+
inventory: [...blobs].map(([logical, bytes]) => ({ path: logical, sha256: hash(bytes) })) };
|
|
99
|
+
input.source.revalidate();
|
|
100
|
+
input.beforeVersions.revalidate();
|
|
101
|
+
input.baseline.revalidate({ projectRoot: frozen.scope.projectRoot, workspace: frozen.scope.workspace, gitExecutable, env: context.env });
|
|
102
|
+
if (hash(readBoundedReleaseFile(gitExecutable, 64 * 1024 * 1024)) !== gitSha256)
|
|
103
|
+
refuse("selected_git_changed");
|
|
104
|
+
const bytes = encode(bundle);
|
|
105
|
+
return RetainedExecutionBundle.admit(bytes, hash(bytes), blobs, { runId, executionScope: frozen.scope,
|
|
106
|
+
bundleProjectRoot: frozen.scope.projectRoot, descriptorSha256: flow.sha256, engineChecksum: checksum,
|
|
107
|
+
engineBindingSha256: engineBinding.sha256, engineManifestSha256: engineManifest.sha256,
|
|
108
|
+
descriptorContext: bundledHandlerContext(new Map()), bundleProvenance: provenance, receipts: input.receipts ?? new Map() });
|
|
109
|
+
}
|