@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,82 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
4
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
5
|
+
import { readFlowRunExecutionBundle } from "./runs.js";
|
|
6
|
+
import { readAcceptedStageData } from "./stage-report-renderer.js";
|
|
7
|
+
import { readReviewFixSourceIntake, assertReviewFixSourceIntakeCustody } from "./review-fix-intake-custody.js";
|
|
8
|
+
import { readPreparedMemoryFlowUserInputs } from "./memory-flow-user-inputs.js";
|
|
9
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
10
|
+
import { assertReviewFixStageScope, validateReviewFixStageResult } from "./review-fix-stage-results.js";
|
|
11
|
+
function refuse(reason) {
|
|
12
|
+
throw new AppError("review_fix_handoff_invalid", "Handoff requires accepted scope review and unchanged original user inputs", 2, { reason, effect: "no_effect" });
|
|
13
|
+
}
|
|
14
|
+
/** Prepare an SQL-fenced raw packet for the ordinary Stage settlement writer.
|
|
15
|
+
* No files, RUNs, PRTs or specifications are created. A ready scope is not
|
|
16
|
+
* authorization to formalize; the user must separately request that action.
|
|
17
|
+
*/
|
|
18
|
+
export function prepareReviewFixHandoff(context, input) {
|
|
19
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
20
|
+
const scope = { projectId: project.id, runId: input.runId };
|
|
21
|
+
const bundle = readFlowRunExecutionBundle(context, input);
|
|
22
|
+
if (!bundle || bundle.manifest.flow_id !== "review-fix")
|
|
23
|
+
refuse("review_fix_bundle_required");
|
|
24
|
+
const source = readReviewFixSourceIntake(context, input, bundle);
|
|
25
|
+
const read = (reader) => {
|
|
26
|
+
const intake = readAcceptedStageData(reader, { ...scope, stage: "intake" });
|
|
27
|
+
const reviewed = readAcceptedStageData(reader, { ...scope, stage: "scope-review" });
|
|
28
|
+
const users = readPreparedMemoryFlowUserInputs(reader, scope, "review-fix", bundle);
|
|
29
|
+
return { intake, reviewed, users };
|
|
30
|
+
};
|
|
31
|
+
const original = context.db.readSnapshot(() => read(context));
|
|
32
|
+
let prior;
|
|
33
|
+
try {
|
|
34
|
+
prior = JSON.parse(original.intake.bytes.toString("utf8"));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return refuse("accepted_intake_invalid");
|
|
38
|
+
}
|
|
39
|
+
if (original.intake.data_schema_id !== "dd-flow/review-fix-stage-result@1"
|
|
40
|
+
|| original.reviewed.data_schema_id !== "dd-flow/review-fix-stage-result@1"
|
|
41
|
+
|| prior?.schema_id !== "dd-flow/review-fix-stage-result@1" || prior.flow_id !== "review-fix"
|
|
42
|
+
|| prior.run_id !== input.runId || prior.stage !== "intake" || prior.intake_sha256 !== source.sha256
|
|
43
|
+
|| prior.input_sha256 !== original.intake.input_sha256 || !original.reviewed.input_sha256)
|
|
44
|
+
refuse("accepted_intake_invalid");
|
|
45
|
+
const reviewed = validateReviewFixStageResult(original.reviewed.bytes, bundle.descriptor.stageBlob("scope-review", "result_schema"), {
|
|
46
|
+
runId: input.runId, stage: "scope-review", inputSha256: original.reviewed.input_sha256, intakeSha256: source.sha256,
|
|
47
|
+
snapshotSha256: prior.snapshot_sha256, userInputsSha256: hybridDigest(original.users),
|
|
48
|
+
findingIds: source.intake.selected_findings.map(finding => finding.id),
|
|
49
|
+
predecessors: [{ stage: "intake", ...original.intake.result }],
|
|
50
|
+
userSources: original.users.sources.map(item => {
|
|
51
|
+
if (!["request", "question", "answer"].includes(item.role))
|
|
52
|
+
return refuse("invalid_user_input_role");
|
|
53
|
+
return { root: item.root, path: item.path, sha256: item.sha256, role: item.role };
|
|
54
|
+
})
|
|
55
|
+
});
|
|
56
|
+
assertReviewFixStageScope(reviewed, prior);
|
|
57
|
+
const disposition = reviewed.details.review.some(item => item.verdict === "revise") ? "needs_scope"
|
|
58
|
+
: reviewed.details.decisions.some(item => item.disposition === "approved") ? "ready"
|
|
59
|
+
: reviewed.details.decisions.some(item => item.disposition === "deferred") ? "deferred" : "declined";
|
|
60
|
+
const bytes = Buffer.from(JSON.stringify({ schema_id: "dd-flow/review-fix-handoff@1", run_id: input.runId,
|
|
61
|
+
disposition, formalization_authorized: false, next_entry: disposition === "ready" ? "vnext/start.md" : null,
|
|
62
|
+
source_intake: source.intake, user_inputs: original.users,
|
|
63
|
+
scope: reviewed.details, accepted_review: { ...original.reviewed.result, authority_sha256: original.reviewed.authority_sha256 }
|
|
64
|
+
}));
|
|
65
|
+
// Keep this packet within the existing Stage reader envelope; never truncate
|
|
66
|
+
// original messages or findings to make an oversized handoff appear valid.
|
|
67
|
+
if (bytes.length > 4 * 1024 * 1024)
|
|
68
|
+
refuse("handoff_exceeds_bound");
|
|
69
|
+
return {
|
|
70
|
+
path: `${bundle.descriptor.stage("handoff").directory}/raw-intake.json`, bytes,
|
|
71
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
72
|
+
assertCustody(writer) {
|
|
73
|
+
assertReviewFixSourceIntakeCustody(writer, scope, bundle, source.sha256);
|
|
74
|
+
const current = read(writer);
|
|
75
|
+
if (!current.intake.bytes.equals(original.intake.bytes) || !current.reviewed.bytes.equals(original.reviewed.bytes)
|
|
76
|
+
|| current.intake.authority_sha256 !== original.intake.authority_sha256
|
|
77
|
+
|| current.reviewed.authority_sha256 !== original.reviewed.authority_sha256
|
|
78
|
+
|| hybridDigest(current.users) !== hybridDigest(original.users))
|
|
79
|
+
refuse("accepted_handoff_changed");
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { assertDiagnosticRepairSourceIntakeCustody, prepareDiagnosticRepairSourceIntake, readDiagnosticRepairSourceIntake } from "./diagnostic-repair-source-intake.js";
|
|
2
|
+
import { readMemoryFlowUserInputs } from "./memory-flow-user-inputs.js";
|
|
3
|
+
// Preserve this flow's own capsule identity; shared transport grants no approval.
|
|
4
|
+
export function assertReviewFixSourceIntakeCustody(context, scope, bundle, expectedSha256) {
|
|
5
|
+
return assertDiagnosticRepairSourceIntakeCustody(context, scope, bundle, expectedSha256, "review-fix");
|
|
6
|
+
}
|
|
7
|
+
export function prepareReviewFixSourceIntake(context, input) {
|
|
8
|
+
return prepareDiagnosticRepairSourceIntake(context, input, "review-fix");
|
|
9
|
+
}
|
|
10
|
+
export function readReviewFixSourceIntake(context, input, retainedBundle) {
|
|
11
|
+
return readDiagnosticRepairSourceIntake(context, input, "review-fix", retainedBundle);
|
|
12
|
+
}
|
|
13
|
+
/** Original request and all delivered answers, not an agent's replacement spec.
|
|
14
|
+
* Reading these bytes neither accepts scope nor authorizes SDLC formalization. */
|
|
15
|
+
export function readReviewFixUserInputs(context, input, retainedBundle) {
|
|
16
|
+
return readMemoryFlowUserInputs(context, input, "review-fix", retainedBundle);
|
|
17
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { reviewFixStages } from "../domain/review-fix-flow.js";
|
|
3
|
+
import { readFlowRunExecutionBundle, assertPreparedRunBundleSqlAuthority } from "./runs.js";
|
|
4
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
5
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
6
|
+
import { readReviewFixSourceIntake, assertReviewFixSourceIntakeCustody } from "./review-fix-intake-custody.js";
|
|
7
|
+
import { PreparedDescriptorStageStart } from "./descriptor-stage-start.js";
|
|
8
|
+
import { PreparedDescriptorStageCoordinator } from "./descriptor-stage-coordinator.js";
|
|
9
|
+
import { readDiagnosticSnapshot, readDiagnosticSnapshotCustody } from "./diagnostic-snapshot.js";
|
|
10
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
11
|
+
/** Internal family entry. Generic runtime owns attempts, Work and coordinator;
|
|
12
|
+
* this does not register a public flow or authorize a downstream SDLC start. */
|
|
13
|
+
export function readReviewFixStageScope(context, input, historical = false) {
|
|
14
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
15
|
+
const bundle = readFlowRunExecutionBundle(context, input);
|
|
16
|
+
if (!bundle || bundle.manifest.flow_id !== "review-fix" || !reviewFixStages.includes(input.stage))
|
|
17
|
+
throw new AppError("review_fix_stage_invalid", "Review-fix requires its registered three-stage flow and retained intake", 2, { reason: "review_fix_stage_required", effect: "no_effect" });
|
|
18
|
+
bundle.descriptor.stage(input.stage);
|
|
19
|
+
const source = readReviewFixSourceIntake(context, input, bundle);
|
|
20
|
+
const scope = { projectId: project.id, runId: input.runId, stage: input.stage };
|
|
21
|
+
const run = assertPreparedRunBundleSqlAuthority(context, scope, bundle);
|
|
22
|
+
const replay = historical && JSON.parse(run.index_json).stage_runs.some((stage) => stage.stage === input.stage && stage.status === "done");
|
|
23
|
+
const frozen = bundle.execution.snapshot().scope;
|
|
24
|
+
const snapshotSelection = { projectId: project.id, projectRoot: frozen.projectRoot, sourceRoot: frozen.projectRoot,
|
|
25
|
+
requestId: frozen.requestId, flowId: "review-fix" };
|
|
26
|
+
const snapshot = (replay ? readDiagnosticSnapshotCustody : readDiagnosticSnapshot)(context, snapshotSelection, input.runId);
|
|
27
|
+
if (!snapshot || snapshot.root !== frozen.workspace)
|
|
28
|
+
throw new AppError("review_fix_stage_invalid", "Review-fix requires its unchanged private source snapshot", 2, { reason: "snapshot_required", effect: "no_effect" });
|
|
29
|
+
return { bundle, source, scope, run, replay, snapshot, snapshotSelection,
|
|
30
|
+
assertCustody(writer) {
|
|
31
|
+
assertReviewFixSourceIntakeCustody(writer, scope, bundle, source.sha256);
|
|
32
|
+
if (hybridDigest(readDiagnosticSnapshotCustody(writer, snapshotSelection, input.runId)) !== hybridDigest(snapshot))
|
|
33
|
+
throw new AppError("review_fix_stage_invalid", "Review-fix source snapshot custody changed", 2, { reason: "snapshot_changed", effect: "no_effect" });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function startReviewFixStage(context, input) {
|
|
38
|
+
const selected = readReviewFixStageScope(context, input), { source } = selected;
|
|
39
|
+
const fence = selected.assertCustody;
|
|
40
|
+
const start = PreparedDescriptorStageStart.prepare(context, input).apply(context, fence);
|
|
41
|
+
const coordinator = PreparedDescriptorStageCoordinator.prepare(context, input).apply(context, fence);
|
|
42
|
+
return { ...start, ...coordinator, intake_sha256: source.sha256, source_intake: source.reference };
|
|
43
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { AppError } from "../shared/errors.js";
|
|
4
|
+
import { entityReferenceVariants } from "../shared/entity-references.js";
|
|
5
|
+
import { reviewFixStages } from "../domain/review-fix-flow.js";
|
|
6
|
+
import { readReviewFixStageScope, startReviewFixStage } from "./review-fix-stage-entry.js";
|
|
7
|
+
import { readDescriptorStageInput } from "./descriptor-stage-coordinator.js";
|
|
8
|
+
import { PreparedDescriptorStageSettlement, readAcceptedDescriptorStageSettlement } from "./descriptor-stage-settlement.js";
|
|
9
|
+
import { PreparedDescriptorRunSettlement } from "./descriptor-run-settlement.js";
|
|
10
|
+
import { readAcceptedStageData, prepareStagePacket, acceptStagePacket, prepareStageReport, acceptStageReport, publishStagePacket, publishStageReport, resolveStagePublicationPath } from "./stage-report-renderer.js";
|
|
11
|
+
import { readPreparedMemoryFlowUserInputs } from "./memory-flow-user-inputs.js";
|
|
12
|
+
import { assertReviewFixStageScope, validateReviewFixStageResult, reviewFixHandoffDisposition } from "./review-fix-stage-results.js";
|
|
13
|
+
import { prepareReviewFixHandoff } from "./review-fix-handoff.js";
|
|
14
|
+
import { assertDiagnosticSessionIndependence } from "./diagnostic-work-collection.js";
|
|
15
|
+
import { readDiagnosticSnapshot } from "./diagnostic-snapshot.js";
|
|
16
|
+
import { stageLifecycleMatchKey } from "./hooks.js";
|
|
17
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
18
|
+
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
19
|
+
function refuse(reason) {
|
|
20
|
+
throw new AppError("review_fix_stage_invalid", "Review-fix requires original scope, independent review and a raw handoff", 2, { reason, effect: "no_effect" });
|
|
21
|
+
}
|
|
22
|
+
export const reviewFixStageHandlers = Object.freeze(reviewFixStages.map(stage => Object.freeze({ flowId: "review-fix", stage,
|
|
23
|
+
entryId: "review-fix-stage-entry", finishId: "review-fix-stage-finish", entry: startReviewFixStage, finish: finishReviewFixStage })));
|
|
24
|
+
/** Read retained raw bytes for terminal replay, never recapture newer messages. */
|
|
25
|
+
function readHandoff(context, scope, attempt, file) {
|
|
26
|
+
const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type='flow_run.stage_packet' AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.stage')='handoff' AND json_extract(payload_json,'$.attempt')=? LIMIT 33", [scope.projectId, scope.runId, attempt]);
|
|
27
|
+
if (rows.length > 32)
|
|
28
|
+
refuse("handoff_custody_invalid");
|
|
29
|
+
const packets = rows.map(row => {
|
|
30
|
+
if (Buffer.byteLength(row.payload_json) > 6 * 1024 * 1024)
|
|
31
|
+
refuse("handoff_exceeds_bound");
|
|
32
|
+
return JSON.parse(row.payload_json);
|
|
33
|
+
}).filter(packet => typeof packet.file === "string" && resolveStagePublicationPath(context, scope.projectId, scope.runId, packet.file) === file);
|
|
34
|
+
if (!packets.length)
|
|
35
|
+
return null;
|
|
36
|
+
if (packets.length !== 1 || typeof packets[0].base64 !== "string")
|
|
37
|
+
refuse("accepted_handoff_missing");
|
|
38
|
+
const bytes = Buffer.from(packets[0].base64, "base64");
|
|
39
|
+
if (!bytes.length || bytes.length > 4 * 1024 * 1024 || bytes.toString("base64") !== packets[0].base64 || hash(bytes) !== packets[0].sha256)
|
|
40
|
+
refuse("accepted_handoff_changed");
|
|
41
|
+
return { bytes, sha256: hash(bytes) };
|
|
42
|
+
}
|
|
43
|
+
/** Generic Stage/RUN settlement remains the sole lifecycle owner. This handler
|
|
44
|
+
* writes only retained runtime packets/reports, never project files or SDLC. */
|
|
45
|
+
export function finishReviewFixStage(context, input) {
|
|
46
|
+
if (!input.bytes.length || input.bytes.length > 1024 * 1024)
|
|
47
|
+
refuse("result_exceeds_bound");
|
|
48
|
+
const selected = readReviewFixStageScope(context, input, true), { bundle, source, scope, replay, snapshot } = selected;
|
|
49
|
+
const packet = readDescriptorStageInput(context, scope, bundle, true, true);
|
|
50
|
+
const stage = input.stage;
|
|
51
|
+
const previousStage = stage === "scope-review" ? "intake" : stage === "handoff" ? "scope-review" : null;
|
|
52
|
+
const previous = previousStage ? readAcceptedStageData(context, { ...scope, stage: previousStage }) : null;
|
|
53
|
+
const prior = previous ? JSON.parse(previous.bytes.toString("utf8")) : null;
|
|
54
|
+
if (previous && (packet.input.predecessor?.stage !== previousStage || hybridDigest(packet.input.predecessor.result) !== hybridDigest(previous.result)))
|
|
55
|
+
refuse("predecessor_changed");
|
|
56
|
+
const bytes = Buffer.from(input.bytes);
|
|
57
|
+
let result;
|
|
58
|
+
const users = !replay ? readPreparedMemoryFlowUserInputs(context, scope, "review-fix", bundle) : null;
|
|
59
|
+
if (replay) {
|
|
60
|
+
const accepted = readAcceptedStageData(context, scope);
|
|
61
|
+
if (!accepted.bytes.equals(bytes))
|
|
62
|
+
refuse("accepted_result_changed");
|
|
63
|
+
result = JSON.parse(accepted.bytes.toString("utf8"));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
result = validateReviewFixStageResult(bytes, bundle.descriptor.stageBlob(stage, "result_schema"), {
|
|
67
|
+
runId: input.runId, stage, inputSha256: packet.inputSha256, intakeSha256: source.sha256,
|
|
68
|
+
snapshotSha256: snapshot.source_sha256, userInputsSha256: hybridDigest(users),
|
|
69
|
+
findingIds: source.intake.selected_findings.map(finding => finding.id), predecessors: previous ? [{ stage: previousStage, ...previous.result }] : [],
|
|
70
|
+
userSources: users.sources.map(item => {
|
|
71
|
+
if (!["request", "question", "answer"].includes(item.role))
|
|
72
|
+
return refuse("invalid_user_input_role");
|
|
73
|
+
return { root: item.root, path: item.path, sha256: item.sha256, role: item.role };
|
|
74
|
+
})
|
|
75
|
+
});
|
|
76
|
+
if (source.intake.limitations.some(item => !result.details.limitations.includes(item)))
|
|
77
|
+
refuse("source_limitations_missing");
|
|
78
|
+
if (prior)
|
|
79
|
+
assertReviewFixStageScope(result, prior);
|
|
80
|
+
}
|
|
81
|
+
const settlement = PreparedDescriptorStageSettlement.prepare(context, input);
|
|
82
|
+
const keys = (name) => entityReferenceVariants(input.runId).map(ref => stageLifecycleMatchKey("stage_finish", ref, name, input.projectRoot));
|
|
83
|
+
const intakeKeys = stage !== "intake" ? keys("intake") : [];
|
|
84
|
+
const reviewKeys = stage === "handoff" ? keys("scope-review") : [];
|
|
85
|
+
const readOwners = (reader) => {
|
|
86
|
+
const physical = (id) => {
|
|
87
|
+
const session = reader.db.get("SELECT harness,provider_session_id FROM sessions WHERE project_id=? AND run_id=? AND session_id=?", [scope.projectId, scope.runId, id]);
|
|
88
|
+
if (!session?.harness || !session.provider_session_id)
|
|
89
|
+
refuse("physical_owner_missing");
|
|
90
|
+
return session;
|
|
91
|
+
};
|
|
92
|
+
const intake = stage !== "intake" ? readAcceptedDescriptorStageSettlement(reader, { ...scope, stage: "intake" }, bundle, intakeKeys) : null;
|
|
93
|
+
const review = stage === "handoff" ? readAcceptedDescriptorStageSettlement(reader, { ...scope, stage: "scope-review" }, bundle, reviewKeys) : null;
|
|
94
|
+
if (intake)
|
|
95
|
+
assertDiagnosticSessionIndependence([physical(review ? review.binding.sessionId : settlement.coordinatorSessionId())], [physical(intake.binding.sessionId)]);
|
|
96
|
+
return { intake, review };
|
|
97
|
+
};
|
|
98
|
+
const owners = context.db.readSnapshot(() => readOwners(context));
|
|
99
|
+
const handoff = stage === "handoff" && !replay ? prepareReviewFixHandoff(context, input) : null;
|
|
100
|
+
const reportRoot = path.join(packet.runRoot, bundle.descriptor.stage(stage).directory), handoffFile = path.join(reportRoot, "raw-intake.json");
|
|
101
|
+
const retained = stage === "handoff" && replay ? readHandoff(context, scope, packet.attempt, handoffFile) : null;
|
|
102
|
+
if (stage === "handoff" && replay && !retained)
|
|
103
|
+
refuse("accepted_handoff_missing");
|
|
104
|
+
const raw = handoff ?? retained;
|
|
105
|
+
const rawPacket = handoff ? prepareStagePacket(handoffFile, handoff.bytes) : null;
|
|
106
|
+
const report = stage === "handoff" && raw ? prepareStageReport({ run_id: input.runId, stage, verdict: "completed", summary: result.summary,
|
|
107
|
+
review_fix: { disposition: reviewFixHandoffDisposition(result), formalization_authorized: false,
|
|
108
|
+
source: source.intake.source, source_intake: source.reference, unselected_ids: source.intake.unselected_ids,
|
|
109
|
+
scope: result.details, raw_intake: { root: "run", path: `${bundle.descriptor.stage(stage).directory}/raw-intake.json`, sha256: raw.sha256 } },
|
|
110
|
+
semantic: { next_action: "No repair or SDLC was started. Ready scope requires a separate explicit user formalization through vnext/start.md; deferred/declined/needs_scope remain honest outcomes." }
|
|
111
|
+
}) : null;
|
|
112
|
+
if (report && Buffer.byteLength(JSON.stringify(report.bytes)) > 7 * 1024 * 1024)
|
|
113
|
+
refuse("terminal_report_exceeds_bound");
|
|
114
|
+
if (!replay && hybridDigest(readDiagnosticSnapshot(context, selected.snapshotSelection, input.runId)) !== hybridDigest(snapshot))
|
|
115
|
+
refuse("snapshot_changed");
|
|
116
|
+
const fence = (writer) => {
|
|
117
|
+
selected.assertCustody(writer);
|
|
118
|
+
if (previous && hybridDigest(readAcceptedStageData(writer, { ...scope, stage: previousStage })) !== hybridDigest(previous))
|
|
119
|
+
refuse("predecessor_changed");
|
|
120
|
+
if (hybridDigest(readOwners(writer)) !== hybridDigest(owners))
|
|
121
|
+
refuse("review_owner_changed");
|
|
122
|
+
if (users && hybridDigest(readPreparedMemoryFlowUserInputs(writer, scope, "review-fix", bundle)) !== hybridDigest(users))
|
|
123
|
+
refuse("delivered_inputs_changed");
|
|
124
|
+
handoff?.assertCustody(writer);
|
|
125
|
+
if (rawPacket && !replay) {
|
|
126
|
+
const existing = readHandoff(writer, scope, packet.attempt, handoffFile);
|
|
127
|
+
if (existing && !existing.bytes.equals(rawPacket.bytes))
|
|
128
|
+
refuse("accepted_handoff_changed");
|
|
129
|
+
if (!existing)
|
|
130
|
+
acceptStagePacket(writer, { ...scope, prepared: rawPacket });
|
|
131
|
+
}
|
|
132
|
+
if (retained && hybridDigest(readHandoff(writer, scope, packet.attempt, handoffFile)) !== hybridDigest(retained))
|
|
133
|
+
refuse("accepted_handoff_changed");
|
|
134
|
+
if (report) {
|
|
135
|
+
const rows = writer.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type='flow_run.stage_report' AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.stage')='handoff' AND json_extract(payload_json,'$.attempt')=? LIMIT 2", [scope.projectId, scope.runId, packet.attempt]);
|
|
136
|
+
if (rows.length > 1 || rows.some(row => Buffer.byteLength(row.payload_json) > 8 * 1024 * 1024))
|
|
137
|
+
refuse("terminal_report_custody_invalid");
|
|
138
|
+
if (rows.length) {
|
|
139
|
+
const stored = JSON.parse(rows[0].payload_json);
|
|
140
|
+
if (stored.sha256 !== report.sha256 || hybridDigest(stored.bytes) !== hybridDigest(report.bytes)
|
|
141
|
+
|| resolveStagePublicationPath(writer, scope.projectId, scope.runId, stored.root) !== reportRoot)
|
|
142
|
+
refuse("terminal_report_changed");
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
if (replay)
|
|
146
|
+
refuse("accepted_terminal_report_missing");
|
|
147
|
+
acceptStageReport(writer, { ...scope, root: reportRoot, prepared: report });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
let acceptedStage = false;
|
|
152
|
+
try {
|
|
153
|
+
const accepted = settlement.apply(context, fence);
|
|
154
|
+
acceptedStage = true;
|
|
155
|
+
if (stage !== "handoff")
|
|
156
|
+
return accepted;
|
|
157
|
+
const complete = PreparedDescriptorRunSettlement.prepare(context, input).apply(context, fence);
|
|
158
|
+
publishStagePacket(context, scope.projectId, scope.runId, stage, handoffFile);
|
|
159
|
+
publishStageReport(context, scope.projectId, scope.runId, stage, reportRoot);
|
|
160
|
+
return { ...accepted, ...complete, disposition: reviewFixHandoffDisposition(result), formalization_authorized: false,
|
|
161
|
+
handoff: handoffFile, reports: ["json", "md", "html"].map(extension => path.join(reportRoot, `stage-report.${extension}`)) };
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (stage !== "handoff")
|
|
165
|
+
throw error;
|
|
166
|
+
if (!acceptedStage) {
|
|
167
|
+
try {
|
|
168
|
+
acceptedStage = readAcceptedStageData(context, scope).bytes.equals(bytes);
|
|
169
|
+
}
|
|
170
|
+
catch { /* Not committed. */ }
|
|
171
|
+
}
|
|
172
|
+
if (!acceptedStage)
|
|
173
|
+
throw error;
|
|
174
|
+
throw new AppError("review_fix_handoff_pending", "Accepted handoff needs projection or RUN settlement reconciliation", 1, { effect: "committed", run_id: input.runId, next_action: "reconcile_accepted_handoff", cause: error instanceof AppError ? error.code : "handoff_settlement_failed" });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
4
|
+
function refuse(reason) {
|
|
5
|
+
throw new AppError("review_fix_stage_result_invalid", "Review-fix requires its selected findings and original delivered user scope", 2, { reason, effect: "no_effect", ...(reason === "invalid_schema" ? { phase: "prepare", recoverable: true } : {}) });
|
|
6
|
+
}
|
|
7
|
+
/** Proposal validation, not semantic approval or physical reviewer independence.
|
|
8
|
+
* Registered handlers must assess actual agreement and independent ownership.
|
|
9
|
+
* There is intentionally no writable scope, specification or SDLC launch field. */
|
|
10
|
+
export function validateReviewFixStageResult(bytes, schemaBytes, binding) {
|
|
11
|
+
if (!bytes.length || bytes.length > 1024 * 1024 || !schemaBytes.length || schemaBytes.length > 1024 * 1024)
|
|
12
|
+
refuse("result_exceeds_bound");
|
|
13
|
+
let result;
|
|
14
|
+
try {
|
|
15
|
+
result = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return refuse("invalid_schema");
|
|
19
|
+
}
|
|
20
|
+
const validate = (() => {
|
|
21
|
+
try {
|
|
22
|
+
const schema = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(schemaBytes));
|
|
23
|
+
if (schema.$id !== "dd-flow/review-fix-stage-result@1")
|
|
24
|
+
refuse("frozen_schema_invalid");
|
|
25
|
+
return new Ajv2020({ strict: true, validateFormats: false }).compile(schema);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return refuse("frozen_schema_invalid");
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
if (!validate(result) || result.schema_id !== "dd-flow/review-fix-stage-result@1" || result.flow_id !== "review-fix"
|
|
32
|
+
|| !result.summary.trim())
|
|
33
|
+
refuse("invalid_schema");
|
|
34
|
+
if (result.run_id !== binding.runId || result.stage !== binding.stage || result.input_sha256 !== binding.inputSha256
|
|
35
|
+
|| result.intake_sha256 !== binding.intakeSha256 || result.snapshot_sha256 !== binding.snapshotSha256
|
|
36
|
+
|| result.user_inputs_sha256 !== binding.userInputsSha256
|
|
37
|
+
|| hybridDigest(result.predecessors) !== hybridDigest(binding.predecessors))
|
|
38
|
+
refuse("foreign_stage_binding");
|
|
39
|
+
const ids = binding.findingIds;
|
|
40
|
+
const complete = (items, expected) => {
|
|
41
|
+
if (new Set(expected).size !== expected.length || items.length !== expected.length
|
|
42
|
+
|| hybridDigest(items.map(item => item.finding_id)) !== hybridDigest(expected))
|
|
43
|
+
refuse("incomplete_finding_decisions");
|
|
44
|
+
};
|
|
45
|
+
if (!ids.length || ids.length > 4096)
|
|
46
|
+
refuse("selected_findings_required");
|
|
47
|
+
complete(result.details.decisions, ids);
|
|
48
|
+
complete(result.details.review, binding.stage === "intake" ? [] : ids);
|
|
49
|
+
for (const decision of result.details.decisions) {
|
|
50
|
+
if (!decision.reason.trim())
|
|
51
|
+
refuse("invalid_schema");
|
|
52
|
+
if (decision.disposition === "approved") {
|
|
53
|
+
if (!decision.approval_source || !binding.userSources.some(source => source.role !== "question"
|
|
54
|
+
&& hybridDigest({ root: source.root, path: source.path, sha256: source.sha256 }) === hybridDigest(decision.approval_source)))
|
|
55
|
+
refuse("approval_not_delivered_by_user");
|
|
56
|
+
}
|
|
57
|
+
else if (decision.approval_source !== null)
|
|
58
|
+
refuse("nonapproved_scope_claim");
|
|
59
|
+
}
|
|
60
|
+
if (result.details.review.some(item => !item.reason.trim()))
|
|
61
|
+
refuse("invalid_schema");
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
export function assertReviewFixStageScope(result, prior) {
|
|
65
|
+
// A real answer delivered during scope-review may revise the intake proposal.
|
|
66
|
+
// The handler must bind the newly read full input set; handoff cannot revise
|
|
67
|
+
// an already reviewed decision or silently consume later user messages.
|
|
68
|
+
const revisedByUser = prior.stage === "intake" && result.stage === "scope-review"
|
|
69
|
+
&& result.user_inputs_sha256 !== prior.user_inputs_sha256;
|
|
70
|
+
if (result.intake_sha256 !== prior.intake_sha256 || result.snapshot_sha256 !== prior.snapshot_sha256
|
|
71
|
+
|| !revisedByUser && (result.user_inputs_sha256 !== prior.user_inputs_sha256
|
|
72
|
+
|| hybridDigest(result.details.decisions) !== hybridDigest(prior.details.decisions))
|
|
73
|
+
|| prior.details.limitations.some(value => !result.details.limitations.includes(value))
|
|
74
|
+
|| prior.stage === "scope-review" && hybridDigest(result.details.review) !== hybridDigest(prior.details.review))
|
|
75
|
+
refuse("accepted_scope_changed");
|
|
76
|
+
}
|
|
77
|
+
/** Honest handoff disposition only; even ready still needs explicit formalization. */
|
|
78
|
+
export function reviewFixHandoffDisposition(result) {
|
|
79
|
+
if (result.stage !== "handoff")
|
|
80
|
+
refuse("handoff_required");
|
|
81
|
+
if (result.details.review.some(item => item.verdict === "revise"))
|
|
82
|
+
return "needs_scope";
|
|
83
|
+
if (result.details.decisions.some(item => item.disposition === "approved"))
|
|
84
|
+
return "ready";
|
|
85
|
+
return result.details.decisions.some(item => item.disposition === "deferred") ? "deferred" : "declined";
|
|
86
|
+
}
|
|
@@ -15,16 +15,57 @@ import { interruptControlledSessions, observeControlledSessions, recordRunContro
|
|
|
15
15
|
import { recoveryGuard, recordRecoveryCapture, sealRunRecovery } from "./run-recovery.js";
|
|
16
16
|
import { createEvalRunSnapshot, verifyRecoverySnapshot } from "./eval-snapshots.js";
|
|
17
17
|
import { recoveryObservationBudget, renewRecoveryObservation, withRecoveryObservation } from "./recovery-observation-budget.js";
|
|
18
|
+
import { acceptedTechnicalWorkControl } from "./controller-result-authority.js";
|
|
19
|
+
import { resumeRunController } from "./run-controller-recovery.js";
|
|
20
|
+
import { managedDaemonExecutionAbsent, readNativeDaemonState } from "./managed-daemon-binding.js";
|
|
18
21
|
export async function reconcileRunControl(context, input) {
|
|
19
22
|
const status = runControlStatus(context, input);
|
|
20
23
|
const control = status.control;
|
|
21
|
-
if (!control?.current || !["requested", "settled"].includes(control.status))
|
|
24
|
+
if (!control?.current || !["requested", "settled", "resuming"].includes(control.status))
|
|
22
25
|
throw new AppError("run_control_state_changed", "Reconciliation requires the current draining/sealed control", 1);
|
|
23
26
|
renewRecoveryObservation({}, input.requestId); // Validate before claiming anything.
|
|
24
27
|
if (control.status === "requested")
|
|
25
28
|
await launchControlWorker(context, { ...input, reconcileId: input.requestId });
|
|
29
|
+
else
|
|
30
|
+
await reconcileAcceptedTechnicalWork(context, input);
|
|
26
31
|
return runControlStatus(context, input);
|
|
27
32
|
}
|
|
33
|
+
/** Explicit, repeatable reconciliation; never converts a human pause to resume. */
|
|
34
|
+
async function reconcileAcceptedTechnicalWork(context, input) {
|
|
35
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
36
|
+
const row = context.db.get("SELECT * FROM run_controllers WHERE project_id=? AND run_id=? AND status NOT IN ('completed','cancelled','superseded')", [project.id, input.runId]);
|
|
37
|
+
if (!row)
|
|
38
|
+
return;
|
|
39
|
+
const requestId = `technical-work:${crypto.createHash("sha256").update(input.controlId).digest("hex")}`;
|
|
40
|
+
const control = context.db.get("SELECT status,recovery_id FROM run_controls WHERE control_id=? AND project_id=? AND run_id=?", [input.controlId, project.id, input.runId]);
|
|
41
|
+
const guard = recoveryGuard(context, project.id, input.runId);
|
|
42
|
+
if (!control || !guard || control.recovery_id !== guard.recovery_id)
|
|
43
|
+
return;
|
|
44
|
+
if (control.status === "settled") {
|
|
45
|
+
if (!acceptedTechnicalWorkControl(context, row, input.controlId))
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
const state = JSON.parse(row.state_json), session = state.sessions[state.current_session];
|
|
49
|
+
const manifest = JSON.parse(row.manifest_json);
|
|
50
|
+
const resourceContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
|
|
51
|
+
if (readNativeDaemonState(session.state_dir).shutdown_state !== "clean" || !managedDaemonExecutionAbsent(resourceContext, { stateDir: session.state_dir, projectId: row.project_id, runId: row.run_id, ownerId: session.owner.owner_id }))
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const validate = () => {
|
|
58
|
+
const current = context.db.get("SELECT * FROM run_controllers WHERE controller_id=?", [row.controller_id]);
|
|
59
|
+
return Boolean(current && current.owner_token === row.owner_token && current.generation === row.generation && acceptedTechnicalWorkControl(context, current, input.controlId));
|
|
60
|
+
};
|
|
61
|
+
await resumeRunController(context, { projectRoot: input.projectRoot, runId: input.runId, controlId: input.controlId, requestId, waitMs: 0 }, validate);
|
|
62
|
+
}
|
|
63
|
+
else if (control.status === "resuming" && guard.generation === row.generation && ["sealed", "resuming", "recovered"].includes(guard.status)) {
|
|
64
|
+
const operation = context.db.get("SELECT status FROM run_controller_operations WHERE controller_id=? AND generation=? AND json_extract(input_json,'$.action')='control_resume' AND json_extract(input_json,'$.request_id')=? AND json_extract(input_json,'$.control_id')=?", [row.controller_id, row.generation, requestId, input.controlId]);
|
|
65
|
+
if (operation?.status === "accepted")
|
|
66
|
+
await resumeRunController(context, { projectRoot: input.projectRoot, runId: input.runId, controlId: input.controlId, requestId, waitMs: 0 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
28
69
|
/** The caller publishes intent and launches a control owner; it never owns drain. */
|
|
29
70
|
export async function requestRunControl(context, input) {
|
|
30
71
|
if (input.waitMs !== undefined && (!Number.isSafeInteger(input.waitMs) || input.waitMs < 0 || input.waitMs > 60_000))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { setFlowRunRecoveryPaused } from "./runs.js";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import { nativeShutdownConfirmed } from "../harness-runtime/lib/native-children.mjs";
|
|
4
|
+
import { readTechnicalNotes, recordTechnicalNote } from "./technical-notes.js";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import { ensureRunControlStorage } from "../storage/database.js";
|
|
6
7
|
import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
|
|
@@ -17,13 +18,15 @@ import { controlStatusReceipt } from "./run-control-receipt.js";
|
|
|
17
18
|
import { assertNativeCancellationReceipt, cancelControlledNativeSession, inspectControlledNativeSession } from "./native-session-control.js";
|
|
18
19
|
import { terminalControllerFinalization } from "./run-controller-state.js";
|
|
19
20
|
import { canonicalNativeOperation } from "../harness-runtime/lib/adapter-timeouts.mjs";
|
|
20
|
-
import { requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
21
|
+
import { requireManagedDaemonBinding, managedDaemonExecutionAbsent } from "./managed-daemon-binding.js";
|
|
22
|
+
import { controllerObligationAccepted, technicalDependencyControl } from "./controller-result-authority.js";
|
|
23
|
+
import { requireController } from "./run-controller-state.js";
|
|
21
24
|
/** Durable projection only: status never probes a provider or acquires an owner. */
|
|
22
25
|
export function runControlStatus(context, input) {
|
|
23
26
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
24
27
|
// One read snapshot prevents a concurrent stop/resume from mixing generations
|
|
25
28
|
// across the request, arbiter and worker projections. Savepoints also nest.
|
|
26
|
-
return context.db.readSnapshot(() => readControlStatus(context, input, project));
|
|
29
|
+
return context.db.readSnapshot(() => ({ ...readControlStatus(context, input, project), technical_notes: readTechnicalNotes(context, project.id, input.runId) }));
|
|
27
30
|
}
|
|
28
31
|
function readControlStatus(context, input, project) {
|
|
29
32
|
const run = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
@@ -78,6 +81,7 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
|
|
|
78
81
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
79
82
|
const control = input.controlId ? context.db.get("SELECT * FROM run_controls WHERE project_id = ? AND run_id = ? AND control_id = ?", [project.id, input.runId, input.controlId]) : undefined;
|
|
80
83
|
const request = control && JSON.parse(control.request_json);
|
|
84
|
+
const technicalPause = Boolean(control && technicalDependencyControl(context, project.id, input.runId, control.control_id));
|
|
81
85
|
const pauseEscalated = control?.action === "pause" && request?.escalate === "interrupt" && Number.isSafeInteger(request.grace_ms) && Date.now() >= Date.parse(control.created_at) + request.grace_ms;
|
|
82
86
|
if (boundary ? interrupt || control : !control || interrupt && !(control.action === "stop" || pauseEscalated) || control.status !== "requested")
|
|
83
87
|
throw new AppError("run_control_invalid_state", "Interrupt requires the current stop intent or its explicitly elapsed pause escalation", 1);
|
|
@@ -293,6 +297,29 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
|
|
|
293
297
|
const nativeVersions = new Map();
|
|
294
298
|
for (const [directory, { state, records }] of daemonInventories) {
|
|
295
299
|
let failedStart = null;
|
|
300
|
+
// Snapshot execution safety is not a claim of clean native shutdown.
|
|
301
|
+
// A technical pause can seal after its real writer is gone even when the
|
|
302
|
+
// third-party session/card never acknowledged completion.
|
|
303
|
+
const physicalContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: registeredProcesses.find(item => records.includes(item.record))?.home ?? resourceHome(context) } };
|
|
304
|
+
let executionAbsent = false;
|
|
305
|
+
if ((boundary || technicalPause) && !interrupt && !conflicts.has(directory) && !unsafeDaemons.has(canonicalPath(directory, false))) {
|
|
306
|
+
try {
|
|
307
|
+
executionAbsent = managedDaemonExecutionAbsent(physicalContext, { stateDir: directory, projectId: project.id, runId: input.runId });
|
|
308
|
+
}
|
|
309
|
+
catch { /* Unknown physical ownership remains on strict reconciliation. */ }
|
|
310
|
+
}
|
|
311
|
+
if (executionAbsent) {
|
|
312
|
+
const leader = records.find(record => record.kind.endsWith("-daemon") && record.id === state.resource_process?.id && record.pid === state.pid && record.lease_token === state.resource_process.lease_token);
|
|
313
|
+
if (leader && (state.daemon_id ?? state.config?.daemonId)) {
|
|
314
|
+
nativeVersions.set(directory, nativeInventoryVersion(directory));
|
|
315
|
+
closedDaemons.set(directory, { source: "owned_execution_absent", daemon_id: state.daemon_id ?? state.config?.daemonId,
|
|
316
|
+
session_ids: state.sessions.map(item => typeof item === "string" ? item : adapterSessionId(item)), execution_settled: true,
|
|
317
|
+
native_cleanup_confirmed: state.shutdown_state === "clean", processes: records.map(record => managedProcessBinding(record, registeredProcesses.find(item => item.record === record).home)) });
|
|
318
|
+
if (state.shutdown_state !== "clean")
|
|
319
|
+
recordTechnicalNote(context, project.id, input.runId, { code: "native_cleanup_pending_after_execution_exit", message: "Owned execution is physically absent; native cleanup acknowledgement remains unavailable.", phase: "capture_observation", generation: recoveryGuard(context, project.id, input.runId)?.generation ?? 0, resource_identity: directory, diagnostic_reference: directory, consequence: "cleanup_pending" });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
296
323
|
// Explicit stop can reconcile a retired owner whose last bookkeeping ACK
|
|
297
324
|
// failed. This is physical retirement, not a fabricated clean shutdown.
|
|
298
325
|
if (control?.action === "stop" && state.shutdown_state === "cleanup_failed")
|
|
@@ -450,6 +477,11 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
|
|
|
450
477
|
if (!Array.isArray(args) || args.some(value => typeof value !== "string") || !saved.state_dir || !saved.executable || saved.owner !== operation.controller_id || conflicts.has(saved.state_dir) || directoryAdapters.get(saved.state_dir) !== saved.executable)
|
|
451
478
|
throw new AppError("controller_operation_binding_unknown", "Unfinished controller operation has no matching frozen runtime binding", 1);
|
|
452
479
|
const method = `${args[0]}.${args[1] === "status" ? "inspect" : args[1]}`;
|
|
480
|
+
if ((boundary || technicalPause) && closedDaemons.get(saved.state_dir)?.source === "owned_execution_absent"
|
|
481
|
+
&& (["daemon.stop", "session.cancel", "session.inspect"].includes(method)
|
|
482
|
+
|| saved.obligation && controllerObligationAccepted(context, requireController(context, operation.controller_id), saved.obligation))) {
|
|
483
|
+
return { operation_id: operation.operation_id, status: "completed", receipt: { source: "accepted_boundary_execution_absent", native_outcome: "unavailable", cleanup_pending: true } };
|
|
484
|
+
}
|
|
453
485
|
if (!["session.create", "session.prompt", "session.start", "session.fork", "session.cancel", "session.resume", "session.inspect", "daemon.start", "daemon.stop"].includes(method))
|
|
454
486
|
throw new AppError("controller_operation_receipt_required", "Unfinished controller operation needs its original receipt", 1);
|
|
455
487
|
const target = args.includes("--session-id") ? args[args.indexOf("--session-id") + 1] : null;
|
|
@@ -1032,7 +1064,7 @@ export function runControlDirective(context, projectId, runId) {
|
|
|
1032
1064
|
return { control_id: current.control_id, action: current.action, status: current.status, stop_turn: true, instruction: "The operator has fenced this RUN. Preserve the recorded result and end this Turn now. Do not start another Work, check, Stage or provider operation, and do not wait for resume in a sleep loop. Only the runtime may authorize continuation.", acknowledged: false, settled: false };
|
|
1033
1065
|
}
|
|
1034
1066
|
/** The durable fence is committed before observation, steering or interruption. */
|
|
1035
|
-
export function recordRunControlIntent(context, input) {
|
|
1067
|
+
export function recordRunControlIntent(context, input, onRecorded) {
|
|
1036
1068
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !["pause", "stop"].includes(input.action)
|
|
1037
1069
|
|| input.graceMs !== undefined && (!Number.isSafeInteger(input.graceMs) || input.graceMs < 0 || input.graceMs > 3_600_000)
|
|
1038
1070
|
|| input.force !== undefined && typeof input.force !== "boolean"
|
|
@@ -1045,11 +1077,14 @@ export function recordRunControlIntent(context, input) {
|
|
|
1045
1077
|
const requestHash = crypto.createHash("sha256").update(JSON.stringify(request)).digest("hex");
|
|
1046
1078
|
ensureRunControlStorage(context.db);
|
|
1047
1079
|
return context.db.writeTransaction(() => {
|
|
1080
|
+
// Internal SQL-only metadata can be published atomically with the intent,
|
|
1081
|
+
// before any detached control worker observes it. A throw rolls back both.
|
|
1082
|
+
const publish = (receipt) => { onRecorded?.(receipt); return receipt; };
|
|
1048
1083
|
const existing = context.db.get("SELECT * FROM run_controls WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, input.runId, input.requestId]);
|
|
1049
1084
|
if (existing) {
|
|
1050
1085
|
if (existing.request_hash !== requestHash)
|
|
1051
1086
|
throw new AppError("runtime_request_conflict", "Control request id has different inputs", 1);
|
|
1052
|
-
return { ok: true, control_id: existing.control_id, recovery_id: existing.recovery_id, action: existing.action, status: existing.status, reused: true };
|
|
1087
|
+
return publish({ ok: true, control_id: existing.control_id, recovery_id: existing.recovery_id, action: existing.action, status: existing.status, reused: true });
|
|
1053
1088
|
}
|
|
1054
1089
|
const run = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
1055
1090
|
if (!run)
|
|
@@ -1087,6 +1122,6 @@ export function recordRunControlIntent(context, input) {
|
|
|
1087
1122
|
WHERE r.project_id = ? AND r.id = ? AND json_extract(stage.value, '$.status') = 'running'
|
|
1088
1123
|
AND EXISTS (SELECT 1 FROM run_control_admissions a JOIN sessions s ON s.session_id = a.session_id AND s.project_id = r.project_id WHERE a.control_id = ? AND s.current_stage = json_extract(stage.value, '$.stage'))`, [controlId, controlId, project.id, input.runId, controlId]);
|
|
1089
1124
|
setFlowRunRecoveryPaused(context, project.id, input.runId, true);
|
|
1090
|
-
return { ok: true, control_id: controlId, recovery_id: recoveryId, generation, action: input.action, status: "requested", settled: false };
|
|
1125
|
+
return publish({ ok: true, control_id: controlId, recovery_id: recoveryId, generation, action: input.action, status: "requested", settled: false });
|
|
1091
1126
|
});
|
|
1092
1127
|
}
|