@deksden-com/dd-flow-cli 0.9.0-beta.144 → 0.9.0-beta.149
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 +130 -0
- package/README.md +13 -8
- package/dist/build-info.json +7 -7
- package/dist/cli/command-inputs.js +13 -3
- package/dist/cli/command-metadata.js +51 -16
- package/dist/cli/diagnostic-invocation.js +46 -0
- package/dist/cli/diagnostic.js +120 -0
- package/dist/cli/help.js +67 -1
- package/dist/cli/hook-ingress.js +27 -3
- package/dist/cli/memory-flow-admission.js +9 -3
- package/dist/cli/memory-init-invocation.js +10 -3
- package/dist/cli/memory-init.js +77 -68
- package/dist/cli/memory-storage-admission.js +3 -2
- package/dist/cli/memory-storage-qualification.js +105 -0
- package/dist/cli/memory-storage-transition.js +123 -0
- package/dist/cli/memory-upgrade.js +189 -0
- package/dist/cli/native-hook-ingress.js +14 -4
- package/dist/cli/native-hook-preparation.js +55 -0
- package/dist/cli/response-file.js +2 -1
- package/dist/cli/run-cli.js +79 -40
- package/dist/cli/setup.js +2 -1
- package/dist/cli.js +18 -0
- package/dist/domain/diagnostic-aspects.js +103 -0
- package/dist/domain/diagnostic-flows.js +8 -0
- package/dist/domain/stage-handler-identities.js +8 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +3 -3
- package/dist/harness-runtime/bin/dd-grok.mjs +3 -3
- package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
- package/dist/harness-runtime/lib/agy-error-boundary.mjs +58 -0
- package/dist/harness-runtime/lib/agy-native-inspection.mjs +216 -0
- package/dist/harness-runtime/lib/agy-native_pb.js +117 -0
- package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +84 -10
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +366 -52
- package/dist/harness-runtime/lib/dd-agy.mjs +17 -3
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +27 -13
- package/dist/harness-runtime/lib/dd-codex.mjs +4 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +4 -3
- package/dist/harness-runtime/lib/dd-droid.mjs +71 -29
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +73 -16
- package/dist/harness-runtime/lib/dd-grok.mjs +6 -3
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +66 -20
- package/dist/harness-runtime/lib/dd-opencode.mjs +30 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +7 -6
- package/dist/harness-runtime/lib/dd-zcode.mjs +44 -14
- package/dist/harness-runtime/lib/delegation-instructions.mjs +1 -1
- package/dist/harness-runtime/lib/driver-recovery.mjs +9 -8
- package/dist/harness-runtime/lib/entity-references.d.mts +2 -0
- package/dist/harness-runtime/lib/entity-references.mjs +9 -0
- package/dist/harness-runtime/lib/managed-daemon.d.mts +1 -1
- package/dist/harness-runtime/lib/managed-daemon.mjs +24 -9
- package/dist/harness-runtime/lib/native-children.d.mts +8 -1
- package/dist/harness-runtime/lib/native-children.mjs +114 -8
- package/dist/harness-runtime/lib/native-hook-command.mjs +4 -4
- package/dist/harness-runtime/lib/native-request-proof.mjs +1 -1
- package/dist/harness-runtime/lib/observation-clock.d.mts +2 -2
- package/dist/harness-runtime/lib/observation-clock.mjs +7 -4
- package/dist/harness-runtime/lib/opencode-plugin.mjs +3 -3
- package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
- package/dist/harness-runtime/lib/provider-overload-policy.d.mts +5 -3
- package/dist/harness-runtime/lib/provider-overload-policy.mjs +20 -7
- package/dist/harness-runtime/lib/runner-events.mjs +3 -1
- package/dist/harness-runtime/lib/setup-progress.mjs +8 -6
- package/dist/harness-runtime/lib/tool-observations.mjs +1 -1
- package/dist/schemas/diagnostic-stage-result.schema.json +354 -0
- package/dist/schemas/diagnostic-work-result.schema.json +296 -0
- package/dist/schemas/execution-bundle.schema.json +7 -1
- package/dist/schemas/mb-upgrade-stage-result.schema.json +863 -0
- package/dist/schemas/memory-repair-work-result.schema.json +9 -2
- package/dist/schemas/project-flow-pack-manifest.schema.json +1 -1
- package/dist/schemas/release-impact.schema.json +3 -3
- package/dist/schemas/stage-start-response.schema.json +4 -0
- package/dist/services/accepted-intent.js +13 -1
- package/dist/services/canon.js +43 -7
- package/dist/services/check-environment.js +21 -0
- package/dist/services/check-runner-evidence.js +267 -15
- package/dist/services/cli-operation-classifier.js +1 -1
- package/dist/services/cli-upgrade-allowlist.js +1 -1
- package/dist/services/code-checks.js +34 -19
- package/dist/services/compatibility-preflight.js +5 -2
- package/dist/services/controller-execution.js +10 -4
- package/dist/services/controller-fanout.js +34 -10
- package/dist/services/controller-memory-task.js +45 -0
- package/dist/services/controller-memory-worker.js +49 -0
- package/dist/services/dashboard.js +2 -2
- package/dist/services/descriptor-run-settlement.js +5 -3
- package/dist/services/descriptor-run-start.js +46 -20
- package/dist/services/descriptor-stage-coordinator.js +66 -13
- package/dist/services/descriptor-stage-settlement.js +2 -1
- package/dist/services/descriptor-stage-start.js +40 -4
- package/dist/services/diagnostic-evidence.js +73 -0
- package/dist/services/diagnostic-report-publication.js +72 -0
- package/dist/services/diagnostic-report.js +67 -0
- package/dist/services/diagnostic-run-bundle.js +74 -0
- package/dist/services/diagnostic-snapshot.js +159 -0
- package/dist/services/diagnostic-stage-handlers.js +108 -0
- package/dist/services/diagnostic-stage-results.js +132 -0
- package/dist/services/diagnostic-start.js +84 -0
- package/dist/services/diagnostic-work-batch.js +34 -0
- package/dist/services/diagnostic-work-collection.js +97 -0
- package/dist/services/diagnostic-work-registration.js +101 -0
- package/dist/services/diagnostic-work-results.js +93 -0
- package/dist/services/engines.js +64 -14
- package/dist/services/eval-snapshots.js +186 -119
- package/dist/services/execution-bundles.js +19 -7
- package/dist/services/external-work-launch.js +5 -3
- package/dist/services/flow-catalog.js +13 -4
- package/dist/services/frozen-engine.js +148 -11
- package/dist/services/hook-memory-routing.js +188 -0
- package/dist/services/hook-recovery-guard.js +14 -0
- package/dist/services/hook-session-observation.js +24 -0
- package/dist/services/hooks.js +27 -8
- package/dist/services/hybrid-contracts.js +4 -13
- package/dist/services/hybrid-digest.js +14 -0
- package/dist/services/installed-flow-source.js +47 -4
- package/dist/services/lifecycle-invocations.js +179 -38
- package/dist/services/memory-accepted-composition.js +5 -4
- package/dist/services/memory-completed-correction-wave.js +23 -15
- package/dist/services/memory-compose-handler.js +163 -21
- package/dist/services/memory-composition-acceptance.js +186 -10
- package/dist/services/memory-composition-graph.js +42 -11
- package/dist/services/memory-composition-registration.js +23 -8
- package/dist/services/memory-composition-work-custody.js +14 -9
- package/dist/services/memory-composition-work-results.js +15 -3
- package/dist/services/memory-corrected-review-batch.js +10 -3
- package/dist/services/memory-corrected-review-findings.js +4 -2
- package/dist/services/memory-corrected-review-inputs.js +10 -5
- package/dist/services/memory-corrected-review-receipt.js +17 -9
- package/dist/services/memory-corrected-review-registration.js +3 -2
- package/dist/services/memory-correction-check-receipt.js +20 -4
- package/dist/services/memory-custody-snapshot.js +36 -0
- package/dist/services/memory-finish-evidence.js +44 -22
- package/dist/services/memory-finish-handler.js +37 -23
- package/dist/services/memory-finish-integration.js +218 -48
- package/dist/services/memory-finish-readiness.js +66 -19
- package/dist/services/memory-git-integration.js +87 -18
- package/dist/services/memory-historical-corrected-review.js +21 -15
- package/dist/services/memory-historical-original-review.js +22 -9
- package/dist/services/memory-historical-repair-registration.js +14 -8
- package/dist/services/memory-historical-second-review.js +30 -11
- package/dist/services/memory-init-contexts.js +44 -2
- package/dist/services/memory-init-controller-start.js +27 -8
- package/dist/services/memory-init-git-policy.js +50 -21
- package/dist/services/memory-init-lint-capability.js +24 -6
- package/dist/services/memory-init-native-capacity.js +17 -4
- package/dist/services/memory-init-preflight.js +21 -17
- package/dist/services/memory-init-request-candidate.js +73 -17
- package/dist/services/memory-init-request-input.js +23 -7
- package/dist/services/memory-init-run-bundle.js +49 -12
- package/dist/services/memory-init-storage.js +17 -7
- package/dist/services/memory-init-worktrunk-capability.js +3 -7
- package/dist/services/memory-layer.js +71 -6
- package/dist/services/memory-lint-config.js +111 -2
- package/dist/services/memory-native-review-copy.js +5 -67
- package/dist/services/memory-normal-flow-entry.js +12 -0
- package/dist/services/memory-ordinary-defaults.js +22 -5
- package/dist/services/memory-preflight-handler.js +15 -0
- package/dist/services/memory-repair-current-view.js +1 -1
- package/dist/services/memory-repair-document-delta.js +70 -33
- package/dist/services/memory-repair-history.js +12 -7
- package/dist/services/memory-repair-physical-binding.js +1 -119
- package/dist/services/memory-repair-publication-plan.js +5 -3
- package/dist/services/memory-repair-publication-source.js +1 -1
- package/dist/services/memory-repair-publication.js +3 -2
- package/dist/services/memory-repair-registration.js +2 -1
- package/dist/services/memory-repair-work-baseline.js +4 -2
- package/dist/services/memory-repair-work-completion.js +32 -12
- package/dist/services/memory-repair-work-results.js +21 -2
- package/dist/services/memory-repair-workspace.js +11 -2
- package/dist/services/memory-repair-writer-copy.js +2 -1
- package/dist/services/memory-result-binding.js +78 -12
- package/dist/services/memory-retained-review-authority.js +17 -8
- package/dist/services/memory-retained-second-baseline.js +1 -1
- package/dist/services/memory-retained-second-repairs.js +12 -6
- package/dist/services/memory-review-correction-plan.js +47 -12
- package/dist/services/memory-review-handler.js +17 -17
- package/dist/services/memory-review-inputs.js +55 -9
- package/dist/services/memory-review-limits.js +10 -0
- package/dist/services/memory-review-receipt.js +2 -2
- package/dist/services/memory-review-registration.js +13 -4
- package/dist/services/memory-review-work-custody.js +3 -1
- package/dist/services/memory-review-work-input.js +26 -7
- package/dist/services/memory-review-work-results.js +26 -4
- package/dist/services/memory-second-corrected-review-inputs.js +5 -3
- package/dist/services/memory-second-correction-plan.js +2 -1
- package/dist/services/memory-second-repair-launch.js +4 -4
- package/dist/services/memory-second-repair-source.js +1 -1
- package/dist/services/memory-stage-dispatch.js +38 -1
- package/dist/services/memory-stage-results.js +113 -22
- package/dist/services/memory-tool-probe.js +25 -0
- package/dist/services/memory-upgrade-analysis-handler.js +243 -0
- package/dist/services/memory-upgrade-bootstrap-readiness.js +251 -0
- package/dist/services/memory-upgrade-evidence.js +100 -0
- package/dist/services/memory-upgrade-inventory.js +442 -0
- package/dist/services/memory-upgrade-publication-custody.js +73 -0
- package/dist/services/memory-upgrade-publication.js +413 -0
- package/dist/services/memory-upgrade-selection.js +430 -0
- package/dist/services/memory-upgrade-start.js +60 -0
- package/dist/services/memory-upgrade-storage-readiness.js +340 -0
- package/dist/services/memory-verification-acceptance.js +38 -17
- package/dist/services/memory-verification-checks.js +92 -27
- package/dist/services/memory-verification-receipt.js +6 -5
- package/dist/services/memory-verify-handler.js +15 -13
- package/dist/services/memory-work-result-json.js +12 -0
- package/dist/services/memory-work-result-preparation.js +5 -4
- package/dist/services/merge-server.js +4 -2
- package/dist/services/migrations.js +127 -98
- package/dist/services/native-daemon-history.js +2 -1
- package/dist/services/physical-startup.js +45 -0
- package/dist/services/project-registry.js +97 -0
- package/dist/services/project-summary.js +1 -1
- package/dist/services/projects.js +3 -93
- package/dist/services/prompt-delivery.js +71 -0
- package/dist/services/provider-capacity.js +112 -46
- package/dist/services/recovery-snapshot-database.js +32 -4
- package/dist/services/release-cache.js +8 -0
- package/dist/services/repair-intents.js +1 -12
- package/dist/services/repair-receipt.js +13 -0
- package/dist/services/run-control-worker.js +35 -18
- package/dist/services/run-control.js +2 -1
- package/dist/services/run-controller-adapter.js +2 -1
- package/dist/services/run-controller-process.js +4 -7
- package/dist/services/run-controller-recovery.js +8 -2
- package/dist/services/run-controller.js +215 -94
- package/dist/services/run-fork.js +1 -1
- package/dist/services/run-recovery-guard.js +30 -0
- package/dist/services/run-recovery.js +11 -44
- package/dist/services/run-start-custody.js +17 -5
- package/dist/services/runs.js +6 -1
- package/dist/services/runtime-scope-resume.js +12 -6
- package/dist/services/runtime-scope-stop.js +3 -2
- package/dist/services/runtime-scope-worker.js +46 -19
- package/dist/services/runtime-service.js +2 -1
- package/dist/services/sessions.js +3 -24
- package/dist/services/setup.js +63 -13
- package/dist/services/stage-handlers.js +22 -7
- package/dist/services/stage-pause.js +111 -44
- package/dist/services/version-status.js +1 -1
- package/dist/services/vnext-code.js +2 -2
- package/dist/services/vnext-fanout.js +51 -9
- package/dist/services/vnext-plan-review.js +3 -5
- package/dist/services/work-registry.js +117 -39
- package/dist/services/workspace-bootstrap.js +2 -1
- package/dist/services/workspace-files.js +1 -1
- package/dist/shared/entity-references.js +1 -8
- package/dist/storage/data-preservation.js +65 -0
- package/dist/storage/database.js +53 -19
- package/dist/storage/import-guard.js +84 -27
- package/dist/storage/memory-upgrade-schema-bootstrap.js +38 -0
- package/dist/storage/paired-writer-migration.js +424 -0
- package/dist/storage/writer-contract.js +11 -4
- package/dist/storage/writer-migration.js +313 -52
- package/dist/storage/writer-restore.js +217 -0
- package/package.json +13 -6
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
3
|
+
import { collectDiagnosticCandidates } from "./diagnostic-work-results.js";
|
|
4
|
+
function refuse(reason) { throw new AppError("diagnostic_report_invalid", "Diagnostic report requires the reviewed candidate and critic registers", 2, { reason, effect: "no_effect" }); }
|
|
5
|
+
/** Projection only. The handler separately owns source checks, physical critic
|
|
6
|
+
* independence, SQL custody, publication and terminal RUN settlement. */
|
|
7
|
+
export function prepareDiagnosticReport(result, analysis, critics) {
|
|
8
|
+
if (result.stage !== "report" || !critics.length
|
|
9
|
+
|| [...analysis, ...critics].some(work => work.workId !== work.result.work_id || work.result.run_id !== result.run_id || work.result.flow_id !== result.flow_id))
|
|
10
|
+
refuse("foreign_work");
|
|
11
|
+
const candidates = collectDiagnosticCandidates(analysis);
|
|
12
|
+
if (hybridDigest(candidates.findings) !== hybridDigest(result.details.findings))
|
|
13
|
+
refuse("candidate_register_changed");
|
|
14
|
+
for (const critic of critics) {
|
|
15
|
+
const ids = critic.result.decisions.map(value => value.finding_id);
|
|
16
|
+
if (critic.result.stage !== "review" || critic.result.findings.length || new Set(ids).size !== ids.length
|
|
17
|
+
|| ids.length !== candidates.findings.length || candidates.findings.some(value => !ids.includes(value.id)))
|
|
18
|
+
refuse("incomplete_critic");
|
|
19
|
+
}
|
|
20
|
+
const decisions = new Map(result.details.decisions.map(value => [value.finding_id, value]));
|
|
21
|
+
if (decisions.size !== candidates.findings.length || result.details.decisions.length !== decisions.size
|
|
22
|
+
|| candidates.findings.some(value => !decisions.has(value.id)))
|
|
23
|
+
refuse("incomplete_resolution");
|
|
24
|
+
const findings = candidates.findings.map((finding, index) => ({ ...finding, origin: candidates.origins[index],
|
|
25
|
+
resolution: decisions.get(finding.id),
|
|
26
|
+
critic_decisions: critics.map(work => ({ work_id: work.workId, decision: work.result.decisions.find(value => value.finding_id === finding.id) })) }));
|
|
27
|
+
const incomplete = result.details.coverage.some(value => value.applicability === "blocked" || value.applicability === "out_of_scope")
|
|
28
|
+
|| result.details.missing_expected_sources.length > 0 || result.details.limitations.length > 0;
|
|
29
|
+
const accepted = findings.filter(value => !["rejected", "duplicate"].includes(value.resolution.disposition));
|
|
30
|
+
const sourceMap = new Map();
|
|
31
|
+
for (const source of [...result.details.sources, ...result.details.coverage.flatMap(value => value.evidence),
|
|
32
|
+
...analysis.flatMap(value => value.result.sources), ...critics.flatMap(value => value.result.sources),
|
|
33
|
+
...findings.flatMap(value => [...value.evidence, ...value.resolution.evidence, ...value.critic_decisions.flatMap(critic => critic.decision.evidence)])]) {
|
|
34
|
+
if (sourceMap.has(source.path) && sourceMap.get(source.path).sha256 !== source.sha256)
|
|
35
|
+
refuse("conflicting_source_versions");
|
|
36
|
+
sourceMap.set(source.path, structuredClone(source));
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
schema_id: "dd-flow/diagnostic-report@1",
|
|
40
|
+
run: { id: result.run_id, flow_id: result.flow_id, snapshot_sha256: result.snapshot_sha256 },
|
|
41
|
+
summary: result.summary, review_focus: structuredClone(result.details.focus), source_map: [...sourceMap.values()],
|
|
42
|
+
aspect_coverage: structuredClone(result.details.coverage),
|
|
43
|
+
critic_pass: critics.map(work => ({ work_id: work.workId, role: work.result.aspect_id, summary: work.result.summary,
|
|
44
|
+
semantic_sha256: hybridDigest(work.result), decisions: structuredClone(work.result.decisions), limitations: [...work.result.limitations] })),
|
|
45
|
+
findings_register: findings,
|
|
46
|
+
conformance_summary: { assessment: incomplete ? "incomplete" : accepted.length ? "findings" : "no_accepted_findings_in_selected_scope",
|
|
47
|
+
accepted_findings: accepted.length, rejected_or_duplicate_findings: findings.length - accepted.length,
|
|
48
|
+
applicable_aspects: result.details.coverage.filter(value => value.applicability === "applicable").length,
|
|
49
|
+
blocked_aspects: result.details.coverage.filter(value => value.applicability === "blocked").length,
|
|
50
|
+
missing_expected_sources: [...result.details.missing_expected_sources] },
|
|
51
|
+
review_fix_recommendation: { authorization: "not_granted", candidate_ids: findings.filter(value => value.resolution.disposition === "review_fix_candidate").map(value => value.id) },
|
|
52
|
+
deferrals: { authorization: "not_granted", candidate_ids: findings.filter(value => value.resolution.disposition === "def_candidate").map(value => value.id),
|
|
53
|
+
observed_sources: [...sourceMap.values()].filter(value => /(?:^|\/)DEF(?:-MBU)?-[^/]+/.test(value.path)) },
|
|
54
|
+
limitations: [...result.details.limitations],
|
|
55
|
+
dashboard_links: critics.map(work => ({ work_id: work.workId, result: `run://${result.run_id}/works/${work.workId}/result.json` }))
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** HTML embeds exactly the authoritative JSON value; untrusted prose is text,
|
|
59
|
+
* never HTML or executable script. Markdown remains a compact companion. */
|
|
60
|
+
export function renderDiagnosticReport(report) {
|
|
61
|
+
const json = Buffer.from(JSON.stringify(report, null, 2) + "\n");
|
|
62
|
+
const escape = (text) => text.replace(/[&<>"']/g, value => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[value]);
|
|
63
|
+
const embedded = json.toString("utf8").replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
64
|
+
const markdown = Buffer.from(`# ${report.run.flow_id} · ${report.run.id}\n\n${report.summary}\n\nAssessment: ${report.conformance_summary.assessment}\n\n${report.findings_register.length} findings retained; ${report.critic_pass.length} independent critic passes.\n\nAuthoritative data: diagnostic-report.json\n`);
|
|
65
|
+
const html = Buffer.from(`<!doctype html><html lang="en"><meta charset="utf-8"><title>${escape(report.run.flow_id)} · ${escape(report.run.id)}</title><body><h1>${escape(report.review_focus.title)}</h1><p>${escape(report.summary)}</p><p>${escape(report.conformance_summary.assessment)}</p><h2>Coverage</h2><ul>${report.aspect_coverage.map(value => `<li>${escape(value.id)}: ${escape(value.applicability)} — ${escape(value.reason)}</li>`).join("")}</ul><h2>Findings</h2><pre>${escape(JSON.stringify(report.findings_register, null, 2))}</pre><script id="diagnostic-report-data" type="application/json">${embedded}</script></body></html>\n`);
|
|
66
|
+
return { json, markdown, html };
|
|
67
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
import { isDiagnosticFlow } from "../domain/diagnostic-flows.js";
|
|
4
|
+
import { PreparedInstalledFlowSource } from "./installed-flow-source.js";
|
|
5
|
+
import { FrozenGenericExecution } from "./generic-execution.js";
|
|
6
|
+
import { AdmittedFrozenEngine } from "./frozen-engine.js";
|
|
7
|
+
import { RetainedExecutionBundle } from "./execution-bundles.js";
|
|
8
|
+
import { readDiagnosticSnapshot } from "./diagnostic-snapshot.js";
|
|
9
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
10
|
+
import { nextDescriptorRunId } from "./descriptor-run-start.js";
|
|
11
|
+
import { bundledHandlerContext } from "./stage-handlers.js";
|
|
12
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
13
|
+
const bytes = (value) => Buffer.from(JSON.stringify(value));
|
|
14
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
15
|
+
function refuse(reason) { throw new AppError("diagnostic_run_bundle_invalid", "Diagnostic bundle requires its frozen source, execution and snapshot", 2, { reason, effect: "no_effect" }); }
|
|
16
|
+
/** Initial construction only. Retry after allocation restores the retained RUN
|
|
17
|
+
* bundle, never current operator settings/canon. boundAt belongs to the request. */
|
|
18
|
+
export function prepareDiagnosticRunBundle(context, input) {
|
|
19
|
+
if (!(input.source instanceof PreparedInstalledFlowSource) || !(input.execution instanceof FrozenGenericExecution)
|
|
20
|
+
|| !(input.engine instanceof AdmittedFrozenEngine) || !Number.isFinite(Date.parse(input.boundAt)))
|
|
21
|
+
refuse("unprepared_selection");
|
|
22
|
+
const frozen = input.execution.snapshot(), flowId = frozen.settings.flow_id;
|
|
23
|
+
if (!isDiagnosticFlow(flowId))
|
|
24
|
+
refuse("diagnostic_flow_required");
|
|
25
|
+
const project = requireProjectByRoot(context, frozen.scope.projectRoot);
|
|
26
|
+
const snapshot = readDiagnosticSnapshot(context, { projectId: project.id, projectRoot: frozen.scope.projectRoot,
|
|
27
|
+
sourceRoot: frozen.scope.projectRoot, requestId: frozen.scope.requestId, flowId });
|
|
28
|
+
if (!snapshot || snapshot.root !== frozen.scope.workspace)
|
|
29
|
+
refuse("snapshot_selection_changed");
|
|
30
|
+
input.source.revalidate();
|
|
31
|
+
const flow = input.source.resolveRegisteredFlow();
|
|
32
|
+
if (flow.descriptor.id !== flowId || flow.sha256 !== frozen.settings.descriptor_sha256)
|
|
33
|
+
refuse("flow_selection_changed");
|
|
34
|
+
input.engine.admitDiagnosticFlow(input.source, flowId);
|
|
35
|
+
const manifest = input.engine.manifest(), checksum = manifest.integrity.checksum;
|
|
36
|
+
if (typeof checksum !== "string")
|
|
37
|
+
refuse("engine_identity_missing");
|
|
38
|
+
const runId = nextDescriptorRunId(context, { projectRoot: frozen.scope.projectRoot, requestId: frozen.scope.requestId, slug: flowId });
|
|
39
|
+
const binding = { schema_id: "dd-flow/run-engine-binding@1", run_id: runId,
|
|
40
|
+
project_root: frozen.scope.projectRoot, bound_at: input.boundAt, source: "run_creation", reason: "Request-bound read-only diagnostic",
|
|
41
|
+
engine: { package_name: manifest.package_name, package_version: manifest.package_version, engine_version: manifest.engine_version,
|
|
42
|
+
integrity_checksum: checksum, snapshot_root: manifest.snapshot_root }, probe: { status: "not_required", command: null } };
|
|
43
|
+
const blobs = input.source.blobs();
|
|
44
|
+
const put = (logical, content) => {
|
|
45
|
+
if (blobs.has(logical))
|
|
46
|
+
refuse("bundle_path_collision");
|
|
47
|
+
blobs.set(logical, content);
|
|
48
|
+
return { path: logical, sha256: hash(content) };
|
|
49
|
+
};
|
|
50
|
+
for (const ref of [frozen.settings.attached_session_receipt, frozen.settings.delegation.native_capacity_receipt]) {
|
|
51
|
+
if (!ref)
|
|
52
|
+
continue;
|
|
53
|
+
const content = input.receiptBytes?.get(ref.sha256);
|
|
54
|
+
if (!content || hash(content) !== ref.sha256 || !input.receipts?.has(ref.sha256))
|
|
55
|
+
refuse("execution_receipt_missing");
|
|
56
|
+
put(ref.path, Buffer.from(content));
|
|
57
|
+
}
|
|
58
|
+
const profiles = { schema_id: "dd-flow/profile-settings@1", profiles: frozen.profiles, aliases: {}, default_profile: null };
|
|
59
|
+
const profile = put("runtime/profiles.json", bytes({ ...profiles, revision: hybridDigest(profiles) }));
|
|
60
|
+
const execution = put("runtime/execution.json", bytes(frozen.settings));
|
|
61
|
+
const engineBinding = put("runtime/engine-binding.json", bytes(binding));
|
|
62
|
+
const engineManifest = put("runtime/engine-manifest.json", input.engine.manifestBytes());
|
|
63
|
+
put("runtime/diagnostic-snapshot.json", bytes(snapshot));
|
|
64
|
+
const provenance = { canon: input.source.canonProvenance(), project_pack: null };
|
|
65
|
+
const bundle = { schema_id: "dd-flow/execution-bundle@1", run_id: runId, flow_id: flowId,
|
|
66
|
+
descriptor: input.source.descriptorReference(), execution_settings: execution, profile_settings: profile,
|
|
67
|
+
engine_binding: engineBinding, engine_manifest: engineManifest, bootstrap_receipts: [], ...provenance,
|
|
68
|
+
inventory: [...blobs].map(([logical, content]) => ({ path: logical, sha256: hash(content) })) };
|
|
69
|
+
const encoded = bytes(bundle);
|
|
70
|
+
return RetainedExecutionBundle.admit(encoded, hash(encoded), blobs, { runId, executionScope: frozen.scope,
|
|
71
|
+
bundleProjectRoot: frozen.scope.projectRoot, descriptorSha256: flow.sha256, engineChecksum: checksum,
|
|
72
|
+
engineBindingSha256: engineBinding.sha256, engineManifestSha256: engineManifest.sha256,
|
|
73
|
+
descriptorContext: bundledHandlerContext(new Map()), bundleProvenance: provenance, receipts: input.receipts ?? new Map() });
|
|
74
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { isDiagnosticFlow } from "../domain/diagnostic-flows.js";
|
|
6
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
7
|
+
import { appendAudit } from "./audit.js";
|
|
8
|
+
import { reviewSourceHash, reviewSourceFilter, reviewExecutionCopyHash } from "./review-copy.js";
|
|
9
|
+
import { projectPacketLocators, runContextRelocations } from "./stage-context.js";
|
|
10
|
+
const intentEvent = "diagnostic.snapshot_intent", readyEvent = "diagnostic.snapshot_ready";
|
|
11
|
+
function refuse(reason) { throw new AppError("diagnostic_snapshot_invalid", "Read-only diagnosis requires its retained unchanged snapshot", 1, { reason, effect: "no_effect" }); }
|
|
12
|
+
const inside = (root, file) => { const relative = path.relative(root, file); return relative === "" || !relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative); };
|
|
13
|
+
function rows(context, input, event) {
|
|
14
|
+
return context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.requestId')=? LIMIT 2", [input.projectId, event, input.requestId]);
|
|
15
|
+
}
|
|
16
|
+
function destination(context, input) {
|
|
17
|
+
return path.join(context.ddFlowHome, "diagnostic-snapshots", hybridDigest(input), "workspace");
|
|
18
|
+
}
|
|
19
|
+
/** Deterministic destination for freezing execution before the first copy. */
|
|
20
|
+
export function diagnosticSnapshotRoot(context, input) {
|
|
21
|
+
validate(context, input);
|
|
22
|
+
return destination(context, input);
|
|
23
|
+
}
|
|
24
|
+
function physicalSnapshot(context, value, runId) {
|
|
25
|
+
return runId ? JSON.parse(projectPacketLocators(JSON.stringify(value), runContextRelocations(context, value.projectId, runId))) : value;
|
|
26
|
+
}
|
|
27
|
+
/** Only physical locators are projected through existing RUN import receipts.
|
|
28
|
+
* The immutable source selection and SQL custody remain byte-for-byte original. */
|
|
29
|
+
export function diagnosticSnapshotPaths(context, value, runId) {
|
|
30
|
+
const physical = physicalSnapshot(context, value, runId);
|
|
31
|
+
return { root: physical.root, sourceRoot: physical.sourceRoot };
|
|
32
|
+
}
|
|
33
|
+
function validate(context, input, runId) {
|
|
34
|
+
const physical = physicalSnapshot(context, input, runId);
|
|
35
|
+
if (!isDiagnosticFlow(input.flowId) || !input.requestId?.trim() || input.requestId.length > 200
|
|
36
|
+
|| !path.isAbsolute(physical.projectRoot) || physical.projectRoot !== physical.sourceRoot
|
|
37
|
+
|| fs.realpathSync(physical.sourceRoot) !== physical.sourceRoot)
|
|
38
|
+
refuse("invalid_selection");
|
|
39
|
+
const project = context.db.get("SELECT root,status FROM projects WHERE id=?", [input.projectId]);
|
|
40
|
+
if (project?.root !== physical.projectRoot || project.status !== "active")
|
|
41
|
+
refuse("project_not_active");
|
|
42
|
+
if (inside(physical.sourceRoot, context.ddFlowHome))
|
|
43
|
+
refuse("runtime_inside_source");
|
|
44
|
+
}
|
|
45
|
+
function retained(context, input, runId) {
|
|
46
|
+
const records = rows(context, input, intentEvent);
|
|
47
|
+
if (records.length > 1)
|
|
48
|
+
refuse("ambiguous_snapshot");
|
|
49
|
+
if (!records.length)
|
|
50
|
+
return null;
|
|
51
|
+
if (Buffer.byteLength(records[0].payload_json) > 16 * 1024)
|
|
52
|
+
refuse("unbounded_snapshot");
|
|
53
|
+
const value = JSON.parse(records[0].payload_json);
|
|
54
|
+
if (value.schema_id !== "dd-flow/diagnostic-snapshot@1" || physicalSnapshot(context, value, runId).root !== destination(context, input)
|
|
55
|
+
|| hybridDigest({ ...value, ...input }) !== hybridDigest(value) || !/^[a-f0-9]{64}$/.test(value.source_sha256))
|
|
56
|
+
refuse("snapshot_selection_changed");
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
function verifyFiles(value, requirePrivateGit = true) {
|
|
60
|
+
if (!fs.lstatSync(value.root).isDirectory() || fs.realpathSync(value.root) !== value.root
|
|
61
|
+
|| reviewSourceHash(value.sourceRoot) !== value.source_sha256
|
|
62
|
+
|| reviewExecutionCopyHash(value.root, value.sourceRoot, false) !== value.source_sha256)
|
|
63
|
+
refuse("source_or_copy_changed");
|
|
64
|
+
if (requirePrivateGit && !fs.lstatSync(path.join(value.root, ".git")).isDirectory())
|
|
65
|
+
refuse("private_git_required");
|
|
66
|
+
}
|
|
67
|
+
function seal(context, input, value) {
|
|
68
|
+
context.db.writeTransaction(() => {
|
|
69
|
+
if (hybridDigest(retained(context, input)) !== hybridDigest(value))
|
|
70
|
+
refuse("snapshot_custody_changed");
|
|
71
|
+
const ready = rows(context, input, readyEvent);
|
|
72
|
+
if (ready.length > 1 || ready.length === 1 && hybridDigest(JSON.parse(ready[0].payload_json)) !== hybridDigest(value))
|
|
73
|
+
refuse("snapshot_custody_changed");
|
|
74
|
+
if (!ready.length)
|
|
75
|
+
appendAudit(context, { projectId: input.projectId, eventType: readyEvent, payload: { ...value } });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** SQL-owned selection plus freshly checked physical source/copy; never adopts a caller's claimed path. */
|
|
79
|
+
export function readDiagnosticSnapshot(context, input, runId) {
|
|
80
|
+
validate(context, input, runId);
|
|
81
|
+
const value = readDiagnosticSnapshotCustody(context, input, runId);
|
|
82
|
+
if (!value)
|
|
83
|
+
return null;
|
|
84
|
+
verifyFiles(physicalSnapshot(context, value, runId));
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
/** SQL-only custody check for an already physically validated selection.
|
|
88
|
+
* Callers must perform readDiagnosticSnapshot before entering a writer. */
|
|
89
|
+
export function readDiagnosticSnapshotCustody(context, input, runId) {
|
|
90
|
+
const value = retained(context, input, runId);
|
|
91
|
+
if (!value)
|
|
92
|
+
return null;
|
|
93
|
+
const ready = rows(context, input, readyEvent);
|
|
94
|
+
if (ready.length !== 1 || hybridDigest(JSON.parse(ready[0].payload_json)) !== hybridDigest(value))
|
|
95
|
+
refuse("snapshot_not_ready");
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
export function assertDiagnosticSnapshot(context, snapshot) {
|
|
99
|
+
const input = { projectId: snapshot.projectId, projectRoot: snapshot.projectRoot, sourceRoot: snapshot.sourceRoot, requestId: snapshot.requestId, flowId: snapshot.flowId };
|
|
100
|
+
const selected = readDiagnosticSnapshot(context, input);
|
|
101
|
+
if (!selected || hybridDigest(selected) !== hybridDigest(snapshot))
|
|
102
|
+
refuse("snapshot_binding_changed");
|
|
103
|
+
}
|
|
104
|
+
/** Copies only established review-visible inputs, never environment files or Git internals.
|
|
105
|
+
* Existing partial copies are verified, not overwritten. Reports belong outside this workspace. */
|
|
106
|
+
export function prepareDiagnosticSnapshot(context, input) {
|
|
107
|
+
validate(context, input);
|
|
108
|
+
const prior = retained(context, input);
|
|
109
|
+
if (prior) {
|
|
110
|
+
if (rows(context, input, readyEvent).length)
|
|
111
|
+
return readDiagnosticSnapshot(context, input);
|
|
112
|
+
verifyFiles(prior);
|
|
113
|
+
seal(context, input, prior);
|
|
114
|
+
return prior;
|
|
115
|
+
}
|
|
116
|
+
const root = destination(context, input), env = { ...context.env, GIT_OPTIONAL_LOCKS: "0" };
|
|
117
|
+
for (const key of Object.keys(env))
|
|
118
|
+
if (key.startsWith("GIT_") && key !== "GIT_OPTIONAL_LOCKS")
|
|
119
|
+
delete env[key];
|
|
120
|
+
const git = (...args) => {
|
|
121
|
+
try {
|
|
122
|
+
return execFileSync("git", ["-c", "core.fsmonitor=false", "-C", input.sourceRoot, ...args], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1024 * 1024 }).trim();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
} // Git absence is explicit missing evidence, never a clean verdict.
|
|
127
|
+
};
|
|
128
|
+
const value = { ...input, schema_id: "dd-flow/diagnostic-snapshot@1", root, source_sha256: reviewSourceHash(input.sourceRoot),
|
|
129
|
+
git: { head: git("rev-parse", "HEAD"), branch: git("symbolic-ref", "--quiet", "--short", "HEAD"), status: git("status", "--porcelain=v1") } };
|
|
130
|
+
let current = context.ddFlowHome;
|
|
131
|
+
for (const part of path.relative(context.ddFlowHome, path.dirname(root)).split(path.sep)) {
|
|
132
|
+
current = path.join(current, part);
|
|
133
|
+
if (fs.existsSync(current)) {
|
|
134
|
+
if (!fs.lstatSync(current).isDirectory() || fs.realpathSync(current) !== current)
|
|
135
|
+
refuse("unsafe_snapshot_parent");
|
|
136
|
+
}
|
|
137
|
+
else
|
|
138
|
+
fs.mkdirSync(current, { mode: 0o700 });
|
|
139
|
+
}
|
|
140
|
+
if (fs.existsSync(root))
|
|
141
|
+
refuse("unowned_snapshot_directory");
|
|
142
|
+
context.db.writeTransaction(() => {
|
|
143
|
+
if (rows(context, input, intentEvent).length)
|
|
144
|
+
refuse("snapshot_already_reserved");
|
|
145
|
+
appendAudit(context, { projectId: input.projectId, eventType: intentEvent, payload: { ...value } });
|
|
146
|
+
});
|
|
147
|
+
fs.mkdirSync(root, { mode: 0o700 });
|
|
148
|
+
fs.cpSync(input.sourceRoot, root, { recursive: true, verbatimSymlinks: true, force: false, errorOnExist: true, filter: reviewSourceFilter(input.sourceRoot) });
|
|
149
|
+
verifyFiles(value, false);
|
|
150
|
+
// A private, independent index makes ordinary controller Git observations safe.
|
|
151
|
+
// No project worktree/index, hooks, commits or branch is changed.
|
|
152
|
+
execFileSync("git", ["-c", "init.templateDir=", "init", "--quiet", "--initial-branch=diagnostic-snapshot", root], { env, stdio: "pipe" });
|
|
153
|
+
// A tracked source can also match .gitignore. Keep the selected safe files
|
|
154
|
+
// tracked in the private index so subsequent review copies cannot omit it.
|
|
155
|
+
execFileSync("git", ["-C", root, "add", "--force", "--all", "--", "."], { env, stdio: "pipe" });
|
|
156
|
+
verifyFiles(value);
|
|
157
|
+
seal(context, input, value);
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { isDiagnosticFlow, diagnosticFlowStages } from "../domain/diagnostic-flows.js";
|
|
3
|
+
import { readFlowRunExecutionBundle } from "./runs.js";
|
|
4
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
5
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
6
|
+
import { diagnosticSnapshotPaths, readDiagnosticSnapshot, readDiagnosticSnapshotCustody } from "./diagnostic-snapshot.js";
|
|
7
|
+
import { PreparedDescriptorStageStart } from "./descriptor-stage-start.js";
|
|
8
|
+
import { PreparedDescriptorStageCoordinator, readDescriptorStageInput } from "./descriptor-stage-coordinator.js";
|
|
9
|
+
import { PreparedDescriptorStageSettlement } from "./descriptor-stage-settlement.js";
|
|
10
|
+
import { PreparedDescriptorRunSettlement } from "./descriptor-run-settlement.js";
|
|
11
|
+
import { assertDiagnosticCriticResolution, assertDiagnosticReportData, assertDiagnosticStageScope, sealDiagnosticStageLimitations, validateDiagnosticStageResult } from "./diagnostic-stage-results.js";
|
|
12
|
+
import { readAcceptedStageData } from "./stage-report-renderer.js";
|
|
13
|
+
import { registerDiagnosticStageGraph } from "./diagnostic-work-registration.js";
|
|
14
|
+
import { PreparedDiagnosticWorkCollection } from "./diagnostic-work-collection.js";
|
|
15
|
+
import { collectDiagnosticCandidates, sealDiagnosticCandidateIds } from "./diagnostic-work-results.js";
|
|
16
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
17
|
+
import { prepareDiagnosticReport } from "./diagnostic-report.js";
|
|
18
|
+
import { prepareDiagnosticReportPublication } from "./diagnostic-report-publication.js";
|
|
19
|
+
export const diagnosticStageHandlers = Object.freeze(Object.entries(diagnosticFlowStages).flatMap(([flowId, stages]) => stages.map(stage => Object.freeze({ flowId, stage, entryId: "diagnostic-stage-entry", finishId: "diagnostic-stage-finish",
|
|
20
|
+
entry: startDiagnosticStage, finish: finishDiagnosticStage }))));
|
|
21
|
+
function refuse(reason) { throw new AppError("diagnostic_stage_invalid", "Diagnostic stage cannot accept this result", 2, { reason, effect: "no_effect" }); }
|
|
22
|
+
function select(context, input) {
|
|
23
|
+
const bundle = readFlowRunExecutionBundle(context, input), project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
24
|
+
if (!bundle || !isDiagnosticFlow(bundle.manifest.flow_id)
|
|
25
|
+
|| !diagnosticFlowStages[bundle.manifest.flow_id].includes(input.stage))
|
|
26
|
+
refuse("registered_diagnostic_stage_required");
|
|
27
|
+
const frozen = bundle.execution.snapshot().scope;
|
|
28
|
+
const snapshotSelection = { projectId: project.id, projectRoot: frozen.projectRoot, sourceRoot: frozen.projectRoot,
|
|
29
|
+
requestId: frozen.requestId, flowId: bundle.manifest.flow_id };
|
|
30
|
+
const snapshot = readDiagnosticSnapshot(context, snapshotSelection, input.runId);
|
|
31
|
+
if (!snapshot || snapshot.root !== frozen.workspace)
|
|
32
|
+
refuse("snapshot_required");
|
|
33
|
+
return { bundle, snapshot, snapshotSelection, scope: { projectId: project.id, runId: input.runId, stage: input.stage } };
|
|
34
|
+
}
|
|
35
|
+
export function startDiagnosticStage(context, input) {
|
|
36
|
+
const selected = select(context, input);
|
|
37
|
+
const fence = (writer) => {
|
|
38
|
+
if (hybridDigest(readDiagnosticSnapshotCustody(writer, selected.snapshotSelection, input.runId)) !== hybridDigest(selected.snapshot))
|
|
39
|
+
refuse("snapshot_changed");
|
|
40
|
+
};
|
|
41
|
+
const start = PreparedDescriptorStageStart.prepare(context, input).apply(context);
|
|
42
|
+
const coordinator = PreparedDescriptorStageCoordinator.prepare(context, input).apply(context, fence);
|
|
43
|
+
if (["audit", "analyze", "review"].includes(input.stage))
|
|
44
|
+
registerDiagnosticStageGraph(context, input);
|
|
45
|
+
return { ...start, ...coordinator };
|
|
46
|
+
}
|
|
47
|
+
/** Findings are data, not permission to repair. Only registered independent Work
|
|
48
|
+
* may contribute analysis/criticism; terminal acceptance closes runtime only. */
|
|
49
|
+
export function finishDiagnosticStage(context, input) {
|
|
50
|
+
const selected = select(context, input), { bundle, snapshot, scope } = selected;
|
|
51
|
+
const stageInput = readDescriptorStageInput(context, scope, bundle, true, true);
|
|
52
|
+
const previousStage = stageInput.input.predecessor?.stage;
|
|
53
|
+
const previous = previousStage ? readAcceptedStageData(context, { ...scope, stage: previousStage }) : null;
|
|
54
|
+
const prior = previous ? JSON.parse(previous.bytes.toString("utf8")) : null;
|
|
55
|
+
const collection = ["audit", "analyze", "review"].includes(input.stage) ? PreparedDiagnosticWorkCollection.prepare(context, input) : null;
|
|
56
|
+
const candidates = ["audit", "analyze"].includes(input.stage) ? sealDiagnosticCandidateIds(input.bytes, collection.results()) : input.bytes;
|
|
57
|
+
const bytes = sealDiagnosticStageLimitations(candidates, [
|
|
58
|
+
...(prior?.details.limitations ?? []), ...(collection?.results() ?? []).flatMap(work => work.result.limitations)
|
|
59
|
+
], prior?.details.missing_expected_sources ?? []);
|
|
60
|
+
const result = validateDiagnosticStageResult(bytes, bundle.descriptor.stageBlob(input.stage, "result_schema"), {
|
|
61
|
+
flowId: selected.snapshotSelection.flowId, runId: input.runId, stage: input.stage,
|
|
62
|
+
inputSha256: stageInput.inputSha256, snapshotSha256: snapshot.source_sha256, snapshotRoot: diagnosticSnapshotPaths(context, snapshot, input.runId).root,
|
|
63
|
+
predecessors: stageInput.input.predecessor ? [{ stage: stageInput.input.predecessor.stage,
|
|
64
|
+
path: stageInput.input.predecessor.result.path, sha256: stageInput.input.predecessor.result.sha256 }] : []
|
|
65
|
+
});
|
|
66
|
+
if (prior) {
|
|
67
|
+
assertDiagnosticStageScope(result, prior);
|
|
68
|
+
if (prior.details.limitations.some(value => !result.details.limitations.includes(value)))
|
|
69
|
+
refuse("limitation_dropped");
|
|
70
|
+
}
|
|
71
|
+
if (collection) {
|
|
72
|
+
const works = collection.results();
|
|
73
|
+
if (works.some(work => work.result.limitations.some(value => !result.details.limitations.includes(value))))
|
|
74
|
+
refuse("worker_limitation_dropped");
|
|
75
|
+
if (input.stage !== "review") {
|
|
76
|
+
if (hybridDigest(result.details.findings) !== hybridDigest(collectDiagnosticCandidates(works).findings))
|
|
77
|
+
refuse("analysis_not_from_work");
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
if (!prior || hybridDigest(result.details.findings) !== hybridDigest(prior.details.findings))
|
|
81
|
+
refuse("candidate_register_changed");
|
|
82
|
+
for (const decision of result.details.decisions) {
|
|
83
|
+
assertDiagnosticCriticResolution(decision, works.map(work => work.result.decisions));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (input.stage === "report")
|
|
88
|
+
assertDiagnosticReportData(result, prior);
|
|
89
|
+
const analysis = input.stage === "report" ? PreparedDiagnosticWorkCollection.prepare(context, { ...input, stage: snapshot.flowId === "mb-audit" ? "audit" : "analyze" }) : null;
|
|
90
|
+
const critics = input.stage === "report" ? PreparedDiagnosticWorkCollection.prepare(context, { ...input, stage: "review" }) : null;
|
|
91
|
+
const publication = analysis && critics ? prepareDiagnosticReportPublication(context, { ...scope, attempt: stageInput.attempt, inputSha256: stageInput.inputSha256 }, prepareDiagnosticReport(result, analysis.results(), critics.results())) : null;
|
|
92
|
+
publication?.publish();
|
|
93
|
+
const fence = (writer) => {
|
|
94
|
+
if (hybridDigest(readDiagnosticSnapshotCustody(writer, selected.snapshotSelection, input.runId)) !== hybridDigest(snapshot))
|
|
95
|
+
refuse("snapshot_changed");
|
|
96
|
+
if (previous && hybridDigest(readAcceptedStageData(writer, { ...scope, stage: previousStage })) !== hybridDigest(previous))
|
|
97
|
+
refuse("predecessor_changed");
|
|
98
|
+
collection?.assertCustody(writer);
|
|
99
|
+
analysis?.assertCustody(writer);
|
|
100
|
+
critics?.assertCustody(writer);
|
|
101
|
+
publication?.retain(writer);
|
|
102
|
+
};
|
|
103
|
+
const settlement = PreparedDescriptorStageSettlement.prepare(context, { ...input, bytes }).apply(context, fence);
|
|
104
|
+
if (input.stage === "report")
|
|
105
|
+
return { ...settlement,
|
|
106
|
+
...PreparedDescriptorRunSettlement.prepare(context, input).apply(context, fence), reports: publication.manifest.artifacts };
|
|
107
|
+
return settlement;
|
|
108
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
2
|
+
import { diagnosticFlowStages } from "../domain/diagnostic-flows.js";
|
|
3
|
+
import { diagnosticAspects } from "../domain/diagnostic-aspects.js";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
6
|
+
import { verifyDiagnosticCoverage, verifyDiagnosticSources } from "./diagnostic-evidence.js";
|
|
7
|
+
const refuse = (reason) => {
|
|
8
|
+
throw new AppError("diagnostic_stage_result_invalid", "Diagnostic result does not match its frozen stage", 2, { reason, effect: "no_effect", ...(reason === "result_schema_invalid" ? { phase: "prepare", recoverable: true } : {}) });
|
|
9
|
+
};
|
|
10
|
+
/** Accepted limitations and missing-source observations belong to predecessor/Work custody, not a coordinator's
|
|
11
|
+
* transcription. Keep its annotations too; validate the complete sealed result
|
|
12
|
+
* with the frozen schema afterwards, without truncating overflowing data. */
|
|
13
|
+
export function sealDiagnosticStageLimitations(bytes, required, acceptedMissingSources = []) {
|
|
14
|
+
if ((!required.length && !acceptedMissingSources.length) || !bytes.length || bytes.length > 1024 * 1024)
|
|
15
|
+
return bytes;
|
|
16
|
+
let value;
|
|
17
|
+
try {
|
|
18
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return bytes;
|
|
22
|
+
}
|
|
23
|
+
let changed = false;
|
|
24
|
+
for (const [key, expected] of [["limitations", required], ["missing_expected_sources", acceptedMissingSources]]) {
|
|
25
|
+
if (!expected.length)
|
|
26
|
+
continue;
|
|
27
|
+
const proposed = value?.details?.[key];
|
|
28
|
+
if (!Array.isArray(proposed) || proposed.some(item => typeof item !== "string"))
|
|
29
|
+
return bytes;
|
|
30
|
+
const missing = [...new Set(expected.filter(item => !proposed.includes(item)))];
|
|
31
|
+
if (missing.length) {
|
|
32
|
+
value.details[key] = [...missing, ...proposed];
|
|
33
|
+
changed = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return changed ? Buffer.from(JSON.stringify(value)) : bytes;
|
|
37
|
+
}
|
|
38
|
+
export function assertDiagnosticStageScope(result, prior) {
|
|
39
|
+
const coverageScope = (value) => value.details.coverage.map(({ id, applicability }) => ({ id, applicability }));
|
|
40
|
+
if (result.details.focus.mode !== prior.details.focus.mode
|
|
41
|
+
// New source gaps are analytical observations, not an expansion of scope.
|
|
42
|
+
// Previously accepted gaps still cannot disappear from the result.
|
|
43
|
+
|| prior.details.missing_expected_sources.some(source => !result.details.missing_expected_sources.includes(source))
|
|
44
|
+
|| hybridDigest(coverageScope(result)) !== hybridDigest(coverageScope(prior)))
|
|
45
|
+
throw new AppError("diagnostic_stage_invalid", "Diagnostic stage cannot accept this result", 2, { reason: "accepted_scope_changed", effect: "no_effect" });
|
|
46
|
+
// Focus prose is retained selection, not a new stage heading. A different
|
|
47
|
+
// proposal is never accepted, but can copy the exact predecessor and retry.
|
|
48
|
+
if (hybridDigest(result.details.focus) !== hybridDigest(prior.details.focus))
|
|
49
|
+
throw new AppError("diagnostic_stage_invalid", "Copy details.focus unchanged from the accepted predecessor; put the stage summary in summary, not the retained focus", 2, { reason: "accepted_focus_changed", effect: "no_effect", phase: "prepare", recoverable: true });
|
|
50
|
+
// Applicability is already accepted. Analytical progress belongs in findings,
|
|
51
|
+
// sources and limitations, not edits to the retained preflight annotations.
|
|
52
|
+
if (hybridDigest(result.details.coverage) !== hybridDigest(prior.details.coverage))
|
|
53
|
+
throw new AppError("diagnostic_stage_invalid", "Copy details.coverage unchanged from the accepted predecessor; put new observations in findings, sources or limitations", 2, { reason: "accepted_coverage_annotations_changed", effect: "no_effect", phase: "prepare", recoverable: true });
|
|
54
|
+
}
|
|
55
|
+
export function assertDiagnosticReportData(result, prior) {
|
|
56
|
+
// Reporting may disclose additional uncertainty, never revise reviewed facts
|
|
57
|
+
// or erase any accepted limitation. This comparison does not rewrite either result.
|
|
58
|
+
if (!prior || prior.details.limitations.some(value => !result.details.limitations.includes(value))
|
|
59
|
+
|| hybridDigest({ ...result.details, limitations: prior.details.limitations }) !== hybridDigest(prior.details))
|
|
60
|
+
throw new AppError("diagnostic_stage_invalid", "Diagnostic stage cannot accept this result", 2, { reason: "report_changed_reviewed_data", effect: "no_effect" });
|
|
61
|
+
}
|
|
62
|
+
export function assertDiagnosticCriticResolution(decision, critics) {
|
|
63
|
+
const selected = decision;
|
|
64
|
+
const votes = critics.flat().filter(item => item.finding_id === decision.finding_id);
|
|
65
|
+
// Duplicate/rejected entries remain trace, not accepted findings. Their
|
|
66
|
+
// supported severity may be reconciled separately without inventing a vote.
|
|
67
|
+
// An accepted finding still requires one critic to endorse the whole pair.
|
|
68
|
+
const nonAccepting = ["duplicate", "rejected"].includes(selected.disposition);
|
|
69
|
+
if (votes.some(item => item.disposition === selected.disposition && item.severity === selected.severity)
|
|
70
|
+
|| nonAccepting && votes.some(item => item.disposition === selected.disposition)
|
|
71
|
+
&& votes.some(item => item.severity === selected.severity))
|
|
72
|
+
return;
|
|
73
|
+
throw new AppError("diagnostic_stage_invalid", "Use a supported critic resolution; preserve all independent critic decisions and explain disagreements", 2, { reason: "unsupported_critic_resolution", effect: "no_effect", phase: "prepare", recoverable: true,
|
|
74
|
+
finding_id: selected.finding_id, supported_resolutions: votes.map(item => ({ disposition: item.disposition, severity: item.severity })) });
|
|
75
|
+
}
|
|
76
|
+
/** Frozen schema and exact CLI-issued identity precede semantic evidence checks.
|
|
77
|
+
* This does not assert that independent Work/critics have settled. */
|
|
78
|
+
export function validateDiagnosticStageResult(bytes, schemaBytes, binding) {
|
|
79
|
+
if (!bytes.length || bytes.length > 1024 * 1024 || schemaBytes.length > 1024 * 1024)
|
|
80
|
+
refuse("result_exceeds_bound");
|
|
81
|
+
const validate = (() => {
|
|
82
|
+
try {
|
|
83
|
+
const schema = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(schemaBytes));
|
|
84
|
+
if (schema.$id !== "dd-flow/diagnostic-stage-result@1")
|
|
85
|
+
refuse("frozen_schema_invalid");
|
|
86
|
+
return new Ajv2020({ strict: false, validateFormats: false }).compile(schema);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return refuse("frozen_schema_invalid");
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
let value;
|
|
93
|
+
try {
|
|
94
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return refuse("result_schema_invalid");
|
|
98
|
+
}
|
|
99
|
+
if (!validate(value))
|
|
100
|
+
refuse("result_schema_invalid");
|
|
101
|
+
if (value.flow_id !== binding.flowId || value.run_id !== binding.runId || value.stage !== binding.stage
|
|
102
|
+
|| !diagnosticFlowStages[binding.flowId].includes(binding.stage)
|
|
103
|
+
|| value.input_sha256 !== binding.inputSha256 || value.snapshot_sha256 !== binding.snapshotSha256
|
|
104
|
+
|| hybridDigest(value.predecessors) !== hybridDigest(binding.predecessors))
|
|
105
|
+
refuse("foreign_result_binding");
|
|
106
|
+
if ((binding.flowId === "mb-audit") !== (value.details.focus.mode === "memory_bank"))
|
|
107
|
+
refuse("foreign_focus");
|
|
108
|
+
verifyDiagnosticCoverage(binding.snapshotRoot, diagnosticAspects[binding.flowId].map(item => item.id), value.details.coverage);
|
|
109
|
+
verifyDiagnosticSources(binding.snapshotRoot, value.details.sources);
|
|
110
|
+
const ids = new Set();
|
|
111
|
+
for (const finding of value.details.findings) {
|
|
112
|
+
if (ids.has(finding.id) || !value.details.coverage.some(item => item.id === finding.aspect_id && item.applicability === "applicable")
|
|
113
|
+
|| !finding.evidence.length)
|
|
114
|
+
refuse("finding_outside_coverage");
|
|
115
|
+
ids.add(finding.id);
|
|
116
|
+
verifyDiagnosticSources(binding.snapshotRoot, finding.evidence);
|
|
117
|
+
}
|
|
118
|
+
const decided = new Set();
|
|
119
|
+
for (const decision of value.details.decisions) {
|
|
120
|
+
if (!ids.has(decision.finding_id) || decided.has(decision.finding_id))
|
|
121
|
+
refuse("foreign_or_duplicate_decision");
|
|
122
|
+
decided.add(decision.finding_id);
|
|
123
|
+
verifyDiagnosticSources(binding.snapshotRoot, decision.evidence);
|
|
124
|
+
}
|
|
125
|
+
if (binding.stage === "preflight" && (ids.size || decided.size))
|
|
126
|
+
refuse("preflight_cannot_claim_analysis");
|
|
127
|
+
if (["audit", "analyze"].includes(binding.stage) && decided.size)
|
|
128
|
+
refuse("analysis_cannot_claim_independent_review");
|
|
129
|
+
if (["review", "report"].includes(binding.stage) && decided.size !== ids.size)
|
|
130
|
+
refuse("unresolved_finding");
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { isDiagnosticFlow } from "../domain/diagnostic-flows.js";
|
|
3
|
+
import { PreparedInstalledFlowSource } from "./installed-flow-source.js";
|
|
4
|
+
import { FrozenGenericExecution } from "./generic-execution.js";
|
|
5
|
+
import { AdmittedFrozenEngine } from "./frozen-engine.js";
|
|
6
|
+
import { prepareDiagnosticSnapshot, diagnosticSnapshotRoot } from "./diagnostic-snapshot.js";
|
|
7
|
+
import { prepareDiagnosticRunBundle } from "./diagnostic-run-bundle.js";
|
|
8
|
+
import { PreparedDescriptorRunStart } from "./descriptor-run-start.js";
|
|
9
|
+
import { readRunStartRequest } from "./run-start-custody.js";
|
|
10
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
11
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
12
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
13
|
+
import { appendAudit } from "./audit.js";
|
|
14
|
+
const event = "diagnostic.request";
|
|
15
|
+
function refuse(reason) { throw new AppError("diagnostic_request_invalid", "Diagnostic request requires its original frozen selection", 2, { reason, effect: "no_effect" }); }
|
|
16
|
+
export function readDiagnosticRequest(context, scope) {
|
|
17
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(scope.projectRoot));
|
|
18
|
+
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, scope.requestId]);
|
|
19
|
+
if (!rows.length)
|
|
20
|
+
return null;
|
|
21
|
+
if (rows.length !== 1 || Buffer.byteLength(rows[0].payload_json) > 48 * 1024 * 1024)
|
|
22
|
+
refuse("ambiguous_request");
|
|
23
|
+
const value = JSON.parse(rows[0].payload_json);
|
|
24
|
+
if (value.schema_id !== "dd-flow/diagnostic-request@1" || value.project_id !== project.id
|
|
25
|
+
|| hybridDigest(value.scope) !== hybridDigest(scope) || !isDiagnosticFlow(value.scope.flowId))
|
|
26
|
+
refuse("request_scope_changed");
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
/** Freeze all ambient choices before copying source or allocating a RUN. */
|
|
30
|
+
export function retainDiagnosticRequest(context, input) {
|
|
31
|
+
const { scope, source, execution, engine } = input;
|
|
32
|
+
if (!(source instanceof PreparedInstalledFlowSource) || !(execution instanceof FrozenGenericExecution)
|
|
33
|
+
|| !(engine instanceof AdmittedFrozenEngine) || !isDiagnosticFlow(scope.flowId) || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$/.test(scope.requestId)
|
|
34
|
+
|| !input.objective.trim() || Buffer.byteLength(input.objective) > 64 * 1024)
|
|
35
|
+
refuse("invalid_selection");
|
|
36
|
+
const project = requireProjectByRoot(context, scope.projectRoot), frozen = execution.snapshot();
|
|
37
|
+
const workspace = diagnosticSnapshotRoot(context, { projectId: project.id, projectRoot: scope.projectRoot, sourceRoot: scope.projectRoot,
|
|
38
|
+
requestId: scope.requestId, flowId: scope.flowId });
|
|
39
|
+
if (frozen.scope.projectRoot !== scope.projectRoot || frozen.scope.requestId !== scope.requestId || frozen.scope.workspace !== workspace
|
|
40
|
+
|| frozen.settings.flow_id !== scope.flowId || frozen.settings.descriptor_sha256 !== source.resolveRegisteredFlow().sha256
|
|
41
|
+
|| frozen.settings.attached_session_receipt || frozen.settings.delegation.native_capacity_receipt)
|
|
42
|
+
refuse("execution_selection_changed");
|
|
43
|
+
source.revalidate();
|
|
44
|
+
engine.admitDiagnosticFlow(source, scope.flowId);
|
|
45
|
+
const manifest = engine.manifest();
|
|
46
|
+
if (typeof manifest.integrity.checksum !== "string")
|
|
47
|
+
refuse("engine_identity_missing");
|
|
48
|
+
const value = { schema_id: "dd-flow/diagnostic-request@1", project_id: project.id, scope: structuredClone(scope),
|
|
49
|
+
created_at: context.now(), objective: input.objective, selection: structuredClone(input.selection),
|
|
50
|
+
source: source.snapshot(), source_sha256: source.sha256, execution: frozen, execution_sha256: execution.sha256,
|
|
51
|
+
engine: { identity: { package_name: manifest.package_name, package_version: manifest.package_version, engine_version: manifest.engine_version,
|
|
52
|
+
integrity_checksum: manifest.integrity.checksum, snapshot_root: manifest.snapshot_root }, manifest_sha256: engine.manifestSha256 } };
|
|
53
|
+
if (Buffer.byteLength(JSON.stringify(value)) > 48 * 1024 * 1024)
|
|
54
|
+
refuse("request_exceeds_bound");
|
|
55
|
+
return context.db.writeTransaction(() => {
|
|
56
|
+
const prior = readDiagnosticRequest(context, scope);
|
|
57
|
+
if (prior) {
|
|
58
|
+
if (hybridDigest({ ...value, created_at: prior.created_at }) !== hybridDigest(prior))
|
|
59
|
+
refuse("request_changed");
|
|
60
|
+
return prior;
|
|
61
|
+
}
|
|
62
|
+
if (readRunStartRequest(context, project.id, scope.requestId))
|
|
63
|
+
refuse("foreign_run_request");
|
|
64
|
+
appendAudit(context, { projectId: project.id, eventType: event, payload: value });
|
|
65
|
+
return value;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export function startDiagnosticRun(context, scope) {
|
|
69
|
+
const retained = readDiagnosticRequest(context, scope);
|
|
70
|
+
if (!retained)
|
|
71
|
+
refuse("original_request_required");
|
|
72
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
73
|
+
if (readRunStartRequest(context, project.id, scope.requestId))
|
|
74
|
+
return PreparedDescriptorRunStart.restore(context, scope).apply(context);
|
|
75
|
+
const snapshot = prepareDiagnosticSnapshot(context, { projectId: project.id, projectRoot: scope.projectRoot, sourceRoot: scope.projectRoot,
|
|
76
|
+
requestId: scope.requestId, flowId: scope.flowId });
|
|
77
|
+
const source = PreparedInstalledFlowSource.restoreRetained(retained.source, retained.source_sha256);
|
|
78
|
+
const execution = FrozenGenericExecution.restore(retained.execution, retained.execution_sha256, source.resolveRegisteredFlow(), {
|
|
79
|
+
requestId: scope.requestId, projectRoot: scope.projectRoot, workspace: snapshot.root
|
|
80
|
+
});
|
|
81
|
+
const engine = AdmittedFrozenEngine.admitSelection(retained.engine.identity, retained.engine.manifest_sha256);
|
|
82
|
+
const bundle = prepareDiagnosticRunBundle(context, { source, execution, engine, boundAt: retained.created_at });
|
|
83
|
+
return PreparedDescriptorRunStart.prepare(context, { bundle }).apply(context);
|
|
84
|
+
}
|