@deksden-com/dd-flow-cli 0.9.0-beta.151 → 0.9.0-beta.155

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/build-info.json +8 -8
  3. package/dist/cli/command-inputs.js +33 -3
  4. package/dist/cli/command-metadata.js +73 -2
  5. package/dist/cli/delivery.js +108 -0
  6. package/dist/cli/diagnostic-invocation.js +23 -7
  7. package/dist/cli/diagnostic.js +14 -3
  8. package/dist/cli/help.js +117 -1
  9. package/dist/cli/hook-ingress.js +36 -49
  10. package/dist/cli/interactive-invocation.js +33 -0
  11. package/dist/cli/interactive.js +114 -0
  12. package/dist/cli/memory-fix-invocation.js +52 -0
  13. package/dist/cli/memory-fix.js +100 -0
  14. package/dist/cli/memory-flow-admission.js +7 -3
  15. package/dist/cli/memory-init.js +5 -4
  16. package/dist/cli/native-hook-ingress.js +5 -1
  17. package/dist/cli/native-hook-preparation.js +31 -4
  18. package/dist/cli/run-cli.js +273 -19
  19. package/dist/cli.js +17 -3
  20. package/dist/domain/delivery-flows.js +8 -0
  21. package/dist/domain/distill-flow.js +20 -0
  22. package/dist/domain/interactive-flow.js +1 -0
  23. package/dist/domain/memory-fix-flow.js +2 -0
  24. package/dist/domain/review-fix-flow.js +2 -0
  25. package/dist/domain/source-snapshot-flows.js +4 -0
  26. package/dist/harness-runtime/bin/dd-agy.mjs +18 -3
  27. package/dist/harness-runtime/bin/dd-grok.mjs +15 -5
  28. package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
  29. package/dist/harness-runtime/lib/agy-error-boundary.mjs +18 -10
  30. package/dist/harness-runtime/lib/agy-native-inspection.mjs +12 -3
  31. package/dist/harness-runtime/lib/agy-native-inventory.mjs +19 -0
  32. package/dist/harness-runtime/lib/agy-terminal-envelope.mjs +13 -6
  33. package/dist/harness-runtime/lib/agy-transcript-stream.mjs +54 -0
  34. package/dist/harness-runtime/lib/daemon-observation.d.mts +1 -0
  35. package/dist/harness-runtime/lib/daemon-observation.mjs +12 -4
  36. package/dist/harness-runtime/lib/daemon-operations.mjs +20 -5
  37. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +1 -0
  38. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +371 -114
  39. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +43 -3
  40. package/dist/harness-runtime/lib/dd-codex.mjs +36 -4
  41. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +25 -11
  42. package/dist/harness-runtime/lib/dd-droid.mjs +124 -11
  43. package/dist/harness-runtime/lib/dd-grok-daemon.d.mts +1 -0
  44. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +137 -25
  45. package/dist/harness-runtime/lib/dd-grok.mjs +4 -2
  46. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +170 -25
  47. package/dist/harness-runtime/lib/dd-opencode.mjs +40 -19
  48. package/dist/harness-runtime/lib/dd-zcode-daemon.d.mts +1 -0
  49. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +115 -22
  50. package/dist/harness-runtime/lib/dd-zcode.mjs +75 -36
  51. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -0
  52. package/dist/harness-runtime/lib/delegation-instructions.mjs +10 -3
  53. package/dist/harness-runtime/lib/delivery-command.mjs +84 -0
  54. package/dist/harness-runtime/lib/driver-recovery.mjs +0 -19
  55. package/dist/harness-runtime/lib/droid-observation.mjs +9 -5
  56. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +1 -0
  57. package/dist/harness-runtime/lib/lifecycle-contract.mjs +2 -1
  58. package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -2
  59. package/dist/harness-runtime/lib/native-hook-command.mjs +63 -8
  60. package/dist/harness-runtime/lib/native-streams.mjs +3 -10
  61. package/dist/harness-runtime/lib/opencode-plugin.mjs +36 -9
  62. package/dist/harness-runtime/lib/operation-errors.mjs +13 -0
  63. package/dist/harness-runtime/lib/operation-progress.mjs +6 -2
  64. package/dist/harness-runtime/lib/process-json.mjs +4 -5
  65. package/dist/harness-runtime/lib/tool-observations.mjs +20 -10
  66. package/dist/harness-runtime/lib/zcode-native-hook-ownership.mjs +109 -0
  67. package/dist/schemas/code-work-result.schema.json +2 -1
  68. package/dist/schemas/delivery-stage-result.schema.json +286 -0
  69. package/dist/schemas/distill-stage-result.schema.json +667 -0
  70. package/dist/schemas/distill-work-result.schema.json +428 -0
  71. package/dist/schemas/execution-bundle.schema.json +6 -2
  72. package/dist/schemas/interactive-stage-result.schema.json +47 -0
  73. package/dist/schemas/interactive-work-result.schema.json +63 -0
  74. package/dist/schemas/mb-fix-stage-result.schema.json +93 -0
  75. package/dist/schemas/project-flow-pack-manifest.schema.json +2 -2
  76. package/dist/schemas/review-fix-stage-result.schema.json +210 -0
  77. package/dist/schemas/run-control-receipt.schema.json +1 -0
  78. package/dist/services/assigned-lifecycle.js +382 -0
  79. package/dist/services/check-runner-evidence.js +77 -10
  80. package/dist/services/check-runner-proof.js +69 -1
  81. package/dist/services/cli-operation-classifier.js +6 -1
  82. package/dist/services/code-checks.js +56 -23
  83. package/dist/services/codex-hook-delivery.js +11 -2
  84. package/dist/services/controller-execution.js +4 -1
  85. package/dist/services/controller-fanout.js +9 -4
  86. package/dist/services/controller-memory-worker.js +6 -4
  87. package/dist/services/controller-result-authority.js +122 -0
  88. package/dist/services/dashboard.js +4 -2
  89. package/dist/services/delivery-access.js +123 -0
  90. package/dist/services/delivery-approval.js +68 -0
  91. package/dist/services/delivery-artifact.js +86 -0
  92. package/dist/services/delivery-checks.js +178 -0
  93. package/dist/services/delivery-execution.js +181 -0
  94. package/dist/services/delivery-intents.js +212 -0
  95. package/dist/services/delivery-operation.js +81 -0
  96. package/dist/services/delivery-policy.js +91 -0
  97. package/dist/services/delivery-procedures.js +43 -0
  98. package/dist/services/delivery-request.js +99 -0
  99. package/dist/services/delivery-result.js +144 -0
  100. package/dist/services/delivery-stage-commands.js +26 -0
  101. package/dist/services/delivery-stage-entry.js +56 -0
  102. package/dist/services/delivery-stage-handlers.js +135 -0
  103. package/dist/services/delivery-stage-results.js +94 -0
  104. package/dist/services/descriptor-coordinator-session.js +3 -1
  105. package/dist/services/descriptor-run-start.js +24 -4
  106. package/dist/services/descriptor-stage-coordinator.js +78 -10
  107. package/dist/services/descriptor-stage-settlement.js +59 -14
  108. package/dist/services/diagnostic-repair-intake.js +123 -0
  109. package/dist/services/diagnostic-repair-source-intake.js +102 -0
  110. package/dist/services/diagnostic-report.js +30 -0
  111. package/dist/services/diagnostic-run-bundle.js +48 -9
  112. package/dist/services/diagnostic-snapshot.js +2 -2
  113. package/dist/services/diagnostic-stage-handlers.js +3 -2
  114. package/dist/services/diagnostic-stage-results.js +26 -0
  115. package/dist/services/diagnostic-start.js +56 -6
  116. package/dist/services/diagnostic-work-collection.js +3 -3
  117. package/dist/services/distill-document-publication.js +280 -0
  118. package/dist/services/distill-promotion.js +197 -0
  119. package/dist/services/distill-publication-custody.js +162 -0
  120. package/dist/services/distill-stage-handlers.js +249 -0
  121. package/dist/services/distill-stage-results.js +105 -0
  122. package/dist/services/distill-user-inputs.js +4 -0
  123. package/dist/services/distill-work-batch.js +42 -0
  124. package/dist/services/distill-work-collection.js +74 -0
  125. package/dist/services/distill-work-registration.js +93 -0
  126. package/dist/services/distill-work-results.js +98 -0
  127. package/dist/services/eval-snapshots.js +17 -7
  128. package/dist/services/external-work-launch.js +8 -2
  129. package/dist/services/flow-catalog.js +24 -0
  130. package/dist/services/frozen-engine.js +19 -2
  131. package/dist/services/generic-execution.js +4 -3
  132. package/dist/services/harness-adapter.js +2 -14
  133. package/dist/services/hook-memory-routing.js +1 -1
  134. package/dist/services/hooks.js +31 -6
  135. package/dist/services/hybrid-contracts.js +6 -1
  136. package/dist/services/installed-flow-source.js +37 -2
  137. package/dist/services/interactive-attached-session.js +215 -0
  138. package/dist/services/interactive-baseline-custody.js +54 -0
  139. package/dist/services/interactive-before-versions.js +148 -0
  140. package/dist/services/interactive-bundle-data.js +85 -0
  141. package/dist/services/interactive-checks.js +179 -0
  142. package/dist/services/interactive-execution.js +33 -0
  143. package/dist/services/interactive-finish-integration.js +236 -0
  144. package/dist/services/interactive-git-baseline.js +230 -0
  145. package/dist/services/interactive-git-dispatch.js +278 -0
  146. package/dist/services/interactive-input-delivery.js +82 -0
  147. package/dist/services/interactive-mutation-scope.js +231 -0
  148. package/dist/services/interactive-request.js +287 -0
  149. package/dist/services/interactive-run-bundle.js +109 -0
  150. package/dist/services/interactive-stage-entry.js +41 -0
  151. package/dist/services/interactive-stage-handlers.js +289 -0
  152. package/dist/services/interactive-stage-observation.js +136 -0
  153. package/dist/services/interactive-stage-results.js +111 -0
  154. package/dist/services/interactive-user-inputs.js +142 -0
  155. package/dist/services/interactive-work-batch.js +43 -0
  156. package/dist/services/interactive-work-collection.js +119 -0
  157. package/dist/services/interactive-work-completion.js +180 -0
  158. package/dist/services/interactive-work-context.js +249 -0
  159. package/dist/services/interactive-work-history.js +112 -0
  160. package/dist/services/interactive-work-registration.js +124 -0
  161. package/dist/services/interactive-work-results.js +115 -0
  162. package/dist/services/interactive-work-start.js +155 -0
  163. package/dist/services/lifecycle-invocations.js +214 -24
  164. package/dist/services/managed-daemon-binding.js +52 -1
  165. package/dist/services/memory-compose-handler.js +2 -22
  166. package/dist/services/memory-finish-integration.js +58 -17
  167. package/dist/services/memory-fix-finish-integration.js +129 -0
  168. package/dist/services/memory-fix-git-dispatch.js +139 -0
  169. package/dist/services/memory-fix-intake-custody.js +11 -0
  170. package/dist/services/memory-fix-intake-settlement.js +127 -0
  171. package/dist/services/memory-fix-publication.js +196 -0
  172. package/dist/services/memory-fix-repair-admission.js +116 -0
  173. package/dist/services/memory-fix-request.js +141 -0
  174. package/dist/services/memory-fix-run-bundle.js +83 -0
  175. package/dist/services/memory-fix-scope.js +78 -0
  176. package/dist/services/memory-fix-sources.js +85 -0
  177. package/dist/services/memory-fix-stage-entry.js +24 -0
  178. package/dist/services/memory-fix-stage-handlers.js +282 -0
  179. package/dist/services/memory-fix-stage-results.js +106 -0
  180. package/dist/services/memory-fix-workspace.js +127 -0
  181. package/dist/services/memory-flow-user-inputs.js +88 -0
  182. package/dist/services/memory-init-contexts.js +55 -5
  183. package/dist/services/memory-init-controller-start.js +20 -4
  184. package/dist/services/memory-layer.js +7 -3
  185. package/dist/services/memory-native-review-copy.js +27 -3
  186. package/dist/services/memory-repair-document-delta.js +1 -0
  187. package/dist/services/project-flow-source.js +195 -0
  188. package/dist/services/project-git-integration.js +92 -0
  189. package/dist/services/repair-intents.js +2 -4
  190. package/dist/services/retained-native-hook.js +245 -0
  191. package/dist/services/review-fix-handoff.js +82 -0
  192. package/dist/services/review-fix-intake-custody.js +17 -0
  193. package/dist/services/review-fix-stage-entry.js +43 -0
  194. package/dist/services/review-fix-stage-handlers.js +176 -0
  195. package/dist/services/review-fix-stage-results.js +86 -0
  196. package/dist/services/run-control-worker.js +42 -1
  197. package/dist/services/run-control.js +40 -5
  198. package/dist/services/run-controller-adapter.js +37 -3
  199. package/dist/services/run-controller-process.js +12 -4
  200. package/dist/services/run-controller-recovery.js +46 -6
  201. package/dist/services/run-controller-state.js +3 -2
  202. package/dist/services/run-controller.js +209 -43
  203. package/dist/services/run-recovery.js +31 -2
  204. package/dist/services/run-timeline.js +59 -0
  205. package/dist/services/runs.js +18 -31
  206. package/dist/services/sessions.js +2 -1
  207. package/dist/services/stage-handlers.js +55 -3
  208. package/dist/services/stage-lifecycle.js +4 -3
  209. package/dist/services/stage-pause.js +26 -9
  210. package/dist/services/stage-report-renderer.js +2 -6
  211. package/dist/services/technical-notes.js +31 -0
  212. package/dist/services/verification-matrix.js +1 -1
  213. package/dist/services/version-status.js +1 -1
  214. package/dist/services/vnext-code-review.js +74 -5
  215. package/dist/services/vnext-code.js +9 -32
  216. package/dist/services/vnext-fanout.js +53 -8
  217. package/dist/services/vnext-merge.js +10 -5
  218. package/dist/services/vnext-plan-review.js +1 -1
  219. package/dist/services/vnext-plan.js +1 -1
  220. package/dist/services/vnext-protocolize.js +1 -1
  221. package/dist/services/vnext-specify.js +10 -5
  222. package/dist/services/work-registry.js +252 -66
  223. package/dist/services/worktrees.js +7 -4
  224. package/dist/storage/database.js +2 -1
  225. package/dist/verification-components.json +82 -0
  226. package/package.json +4 -3
  227. package/tools/verification-jobs.mjs +342 -0
@@ -0,0 +1,230 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { spawnSync } from "node:child_process";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { hybridDigest } from "./hybrid-contracts.js";
7
+ const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
8
+ function refuse(reason) {
9
+ throw new AppError("interactive_git_baseline_invalid", "Interactive needs its actual project/workspace Git baseline", 2, { reason, effect: "no_effect" });
10
+ }
11
+ function logical(value) {
12
+ return typeof value === "string" && value.length > 0 && value.length <= 4096 && !value.includes("\\") && !value.includes("\0")
13
+ && !path.isAbsolute(value) && value.split("/").every(part => part && part !== "." && part !== ".." && part !== ".git");
14
+ }
15
+ /** Hash regular files incrementally: large assets do not require a second full
16
+ * in-memory copy. Do not follow symlinks, read FIFOs or invoke Git clean filters. */
17
+ function entry(root, name) {
18
+ const file = path.join(root, name);
19
+ let parent = root;
20
+ for (const part of name.split("/").slice(0, -1)) {
21
+ parent = path.join(parent, part);
22
+ try {
23
+ if (!fs.lstatSync(parent).isDirectory())
24
+ return { path: name, kind: "missing", mode: null, sha256: null };
25
+ }
26
+ catch (error) {
27
+ if (error.code === "ENOENT")
28
+ return { path: name, kind: "missing", mode: null, sha256: null };
29
+ throw error;
30
+ }
31
+ }
32
+ let stat;
33
+ try {
34
+ stat = fs.lstatSync(file);
35
+ }
36
+ catch (error) {
37
+ if (error.code === "ENOENT")
38
+ return { path: name, kind: "missing", mode: null, sha256: null };
39
+ throw error;
40
+ }
41
+ const mode = stat.mode & 0o7777;
42
+ if (stat.isSymbolicLink())
43
+ return { path: name, kind: "link", mode, sha256: hash(fs.readlinkSync(file, { encoding: "buffer" })) };
44
+ // Gitlinks/submodules are opaque. Their nested working tree is not included
45
+ // in this parent repository's git-visible scope or a writable-file delta.
46
+ if (stat.isDirectory())
47
+ return { path: name, kind: "directory", mode, sha256: null };
48
+ if (!stat.isFile())
49
+ refuse("special_file");
50
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
51
+ try {
52
+ const before = fs.fstatSync(fd, { bigint: true }), digest = createHash("sha256"), buffer = Buffer.allocUnsafe(256 * 1024);
53
+ if (before.dev !== BigInt(stat.dev) || before.ino !== BigInt(stat.ino) || !before.isFile())
54
+ refuse("file_changed_during_capture");
55
+ let length = 0n;
56
+ for (;;) {
57
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
58
+ if (!count)
59
+ break;
60
+ digest.update(buffer.subarray(0, count));
61
+ length += BigInt(count);
62
+ }
63
+ const after = fs.fstatSync(fd, { bigint: true });
64
+ if (length !== before.size || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs)
65
+ refuse("file_changed_during_capture");
66
+ return { path: name, kind: "file", mode, sha256: digest.digest("hex") };
67
+ }
68
+ finally {
69
+ fs.closeSync(fd);
70
+ }
71
+ }
72
+ function stamp(root, name) {
73
+ try {
74
+ let parent = root;
75
+ for (const part of name.split("/").slice(0, -1)) {
76
+ parent = path.join(parent, part);
77
+ if (!fs.lstatSync(parent).isDirectory())
78
+ return null;
79
+ }
80
+ const value = fs.lstatSync(path.join(root, name), { bigint: true });
81
+ return [value.dev, value.ino, value.mode, value.size, value.mtimeNs, value.ctimeNs].map(String);
82
+ }
83
+ catch (error) {
84
+ if (["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
85
+ return null;
86
+ throw error;
87
+ }
88
+ }
89
+ function observe(input, retainedPaths = []) {
90
+ if (!path.isAbsolute(input.gitExecutable))
91
+ refuse("absolute_git_executable_required");
92
+ for (const root of [input.projectRoot, input.workspace]) {
93
+ if (!path.isAbsolute(root) || fs.realpathSync(root) !== root || !fs.lstatSync(root).isDirectory())
94
+ refuse("canonical_workspace_required");
95
+ }
96
+ const env = { ...Object.fromEntries(Object.entries(input.env).filter(([key]) => !key.startsWith("GIT_"))), GIT_OPTIONAL_LOCKS: "0" };
97
+ const git = (root, args, absent = false) => {
98
+ const result = spawnSync(input.gitExecutable, ["-c", "core.fsmonitor=false", "-C", root, ...args], { env, timeout: 15000, maxBuffer: 32 * 1024 * 1024 });
99
+ if (result.error || result.signal || result.status !== 0 && !(absent && [1, 128].includes(result.status ?? -1)))
100
+ refuse("git_observation_failed");
101
+ return result.status === 0 ? result.stdout : null;
102
+ };
103
+ const text = (root, args, absent = false) => git(root, args, absent)?.toString("utf8").trim() ?? null;
104
+ const repository = fs.realpathSync(text(input.projectRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
105
+ if (fs.realpathSync(text(input.workspace, ["rev-parse", "--path-format=absolute", "--git-common-dir"])) !== repository
106
+ || text(input.projectRoot, ["rev-parse", "--show-toplevel"]) !== input.projectRoot
107
+ || text(input.workspace, ["rev-parse", "--show-toplevel"]) !== input.workspace)
108
+ refuse("foreign_workspace_repository");
109
+ if (git(input.workspace, ["ls-files", "--unmerged", "-z"]).length)
110
+ refuse("unmerged_index");
111
+ const identity = fs.statSync(input.workspace);
112
+ const meta = () => {
113
+ const index = text(input.workspace, ["rev-parse", "--path-format=absolute", "--git-path", "index"]);
114
+ let indexHash = null;
115
+ try {
116
+ indexHash = hash(fs.readFileSync(index));
117
+ }
118
+ catch (error) {
119
+ if (error.code !== "ENOENT")
120
+ throw error;
121
+ }
122
+ return { head: text(input.workspace, ["rev-parse", "--verify", "HEAD"], true), branch: text(input.workspace, ["symbolic-ref", "--quiet", "--short", "HEAD"], true),
123
+ index_sha256: indexHash, status_base64: git(input.workspace, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=all"]).toString("base64") };
124
+ };
125
+ const names = () => {
126
+ const bytes = git(input.workspace, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]);
127
+ let value;
128
+ try {
129
+ value = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
130
+ }
131
+ catch {
132
+ return refuse("non_utf8_file_name");
133
+ }
134
+ const items = [...new Set(value.split("\0").filter(Boolean))].sort();
135
+ if (items.length > 200_000 || items.some(name => !logical(name)))
136
+ refuse("unsafe_or_unbounded_inventory");
137
+ return items;
138
+ };
139
+ const before = meta(), visible = names(), paths = [...new Set([...visible, ...retainedPaths])].sort();
140
+ if (paths.length > 200_000 || paths.some(name => !logical(name)))
141
+ refuse("unsafe_or_unbounded_inventory");
142
+ const stamps = paths.map(name => stamp(input.workspace, name)), entries = paths.map(name => entry(input.workspace, name));
143
+ const currentRoot = fs.statSync(input.workspace);
144
+ if (identity.dev !== currentRoot.dev || identity.ino !== currentRoot.ino || hybridDigest(stamps) !== hybridDigest(paths.map(name => stamp(input.workspace, name)))
145
+ || hybridDigest(before) !== hybridDigest(meta()) || hybridDigest(visible) !== hybridDigest(names()))
146
+ refuse("workspace_changed_during_capture");
147
+ return { schema_id: "dd-flow/interactive-git-baseline@1", project_root: input.projectRoot, workspace: input.workspace,
148
+ repository, workspace_identity: { dev: identity.dev, ino: identity.ino }, ...before, visibility: "git-visible-and-retained@1", entries };
149
+ }
150
+ /** Read-only physical baseline, not permission to edit/commit existing dirty
151
+ * files. The RUN owner must retain this in its admitted bundle before work.
152
+ * Ignored files and opaque submodule contents are explicitly outside visibility. */
153
+ export class PreparedInteractiveGitBaseline {
154
+ sha256;
155
+ #value;
156
+ constructor(value) {
157
+ if (Buffer.byteLength(JSON.stringify(value)) > 32 * 1024 * 1024)
158
+ refuse("unbounded_baseline");
159
+ this.#value = structuredClone(value);
160
+ this.sha256 = hash(Buffer.from(JSON.stringify(value)));
161
+ Object.freeze(this);
162
+ }
163
+ static capture(input) { return new PreparedInteractiveGitBaseline(observe(input)); }
164
+ /** Parsing only. Expected bytes/hash must come from the RUN's admitted bundle,
165
+ * not a caller's self-hashed replacement or a current project observation. */
166
+ static restore(bytes, expectedSha256) {
167
+ if (!bytes.length || bytes.length > 32 * 1024 * 1024 || !/^[a-f0-9]{64}$/.test(expectedSha256) || hash(bytes) !== expectedSha256)
168
+ refuse("retained_baseline_changed");
169
+ let value;
170
+ try {
171
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
172
+ }
173
+ catch {
174
+ return refuse("invalid_retained_baseline");
175
+ }
176
+ const checksum = (item) => typeof item === "string" && /^[a-f0-9]{64}$/.test(item);
177
+ if (!value || Object.keys(value).sort().join() !== "branch,entries,head,index_sha256,project_root,repository,schema_id,status_base64,visibility,workspace,workspace_identity"
178
+ || value.schema_id !== "dd-flow/interactive-git-baseline@1" || value.visibility !== "git-visible-and-retained@1"
179
+ || [value.project_root, value.workspace, value.repository].some(item => typeof item !== "string" || !path.isAbsolute(item))
180
+ || !value.workspace_identity || Object.keys(value.workspace_identity).sort().join() !== "dev,ino"
181
+ || Object.values(value.workspace_identity).some(item => !Number.isSafeInteger(item) || item < 0)
182
+ || value.branch !== null && (typeof value.branch !== "string" || !value.branch)
183
+ || value.head !== null && (typeof value.head !== "string" || !/^[a-f0-9]{40,64}$/.test(value.head))
184
+ || value.index_sha256 !== null && !checksum(value.index_sha256)
185
+ || typeof value.status_base64 !== "string" || Buffer.from(value.status_base64, "base64").toString("base64") !== value.status_base64
186
+ || !Array.isArray(value.entries) || value.entries.length > 200_000)
187
+ refuse("invalid_retained_baseline");
188
+ let previous = "";
189
+ for (const item of value.entries) {
190
+ if (!item || Object.keys(item).sort().join() !== "kind,mode,path,sha256" || !logical(item.path) || item.path <= previous
191
+ || !["file", "link", "directory", "missing"].includes(item.kind)
192
+ || (item.kind === "missing" ? item.mode !== null : !Number.isInteger(item.mode) || item.mode < 0 || item.mode > 0o7777)
193
+ || (["file", "link"].includes(item.kind) ? !checksum(item.sha256) : item.sha256 !== null))
194
+ refuse("invalid_retained_inventory");
195
+ previous = item.path;
196
+ }
197
+ const restored = new PreparedInteractiveGitBaseline(value);
198
+ if (restored.sha256 !== expectedSha256)
199
+ refuse("retained_baseline_changed");
200
+ return restored;
201
+ }
202
+ snapshot() { return structuredClone(this.#value); }
203
+ bytes() { return Buffer.from(JSON.stringify(this.#value)); }
204
+ revalidate(input) {
205
+ if (PreparedInteractiveGitBaseline.capture(input).sha256 !== this.sha256)
206
+ refuse("baseline_changed_before_retention");
207
+ }
208
+ observeChanges(input, retainedPaths = []) {
209
+ const current = new PreparedInteractiveGitBaseline(observe(input, [...this.#value.entries.map(item => item.path), ...retainedPaths]));
210
+ return { current, delta: this.changes(current) };
211
+ }
212
+ /** Exact working-file delta relative to entry, not HEAD. Pre-existing dirty
213
+ * content is not automatically attributed to this interactive RUN. Git/index
214
+ * movement is reported separately, never silently approved as integration. */
215
+ changes(current) {
216
+ const before = this.#value, after = current.#value;
217
+ if (before.project_root !== after.project_root || before.workspace !== after.workspace || before.repository !== after.repository
218
+ || hybridDigest(before.workspace_identity) !== hybridDigest(after.workspace_identity) || before.branch !== after.branch)
219
+ refuse("workspace_identity_changed");
220
+ const left = new Map(before.entries.map(item => [item.path, item])), right = new Map(after.entries.map(item => [item.path, item]));
221
+ if ([...left.keys()].some(name => !right.has(name)))
222
+ refuse("baseline_paths_not_observed");
223
+ const changes = [...new Set([...left.keys(), ...right.keys()])].sort().flatMap(name => {
224
+ const old = left.get(name)?.kind === "missing" ? null : left.get(name) ?? null, next = right.get(name)?.kind === "missing" ? null : right.get(name) ?? null;
225
+ return hybridDigest(old) === hybridDigest(next) ? [] : [{ path: name, before: old, after: next }];
226
+ });
227
+ return { baseline_sha256: this.sha256, current_sha256: current.sha256, changes,
228
+ head_changed: before.head !== after.head, index_changed: before.index_sha256 !== after.index_sha256 };
229
+ }
230
+ }
@@ -0,0 +1,278 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { AppError } from "../shared/errors.js";
3
+ import { hybridDigest } from "./hybrid-contracts.js";
4
+ import { appendAudit } from "./audit.js";
5
+ import { assertRunMutationAllowed } from "./run-recovery-guard.js";
6
+ import { readInteractiveStageScope } from "./interactive-stage-entry.js";
7
+ import { prepareInteractiveReviewedGitInputs } from "./interactive-finish-integration.js";
8
+ import { readInteractiveWorkGit } from "./interactive-work-start.js";
9
+ import { readDescriptorStageInput } from "./descriptor-stage-coordinator.js";
10
+ import { readActiveDescriptorStageCoordinator } from "./descriptor-stage-settlement.js";
11
+ import { integrateReviewedProjectFiles } from "./memory-finish-integration.js";
12
+ import { observeReviewedProjectGitIntegration } from "./project-git-integration.js";
13
+ const event = "interactive.integration_operation", phaseEvent = "interactive.integration_intent", anchorPath = '$.variables."interactive.integration"';
14
+ function refuse(reason) {
15
+ throw new AppError("interactive_git_dispatch_invalid", "Interactive Git requires its original reviewed operation and active coordinator", 2, { reason, effect: "no_effect" });
16
+ }
17
+ function pending(reason) {
18
+ throw new AppError("interactive_integration_pending", "Reconcile the original Git effect before dispatching again", 2, { reason, effect: "unknown", recoverable: true, next_action: "reconcile_original_integration" });
19
+ }
20
+ function stored(context, scope) {
21
+ const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.plan.run_id')=? LIMIT 2", [scope.projectId, event, scope.runId]);
22
+ const phases = context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.run_id')=? ORDER BY id LIMIT 3", [scope.projectId, phaseEvent, scope.runId]);
23
+ const anchor = context.db.get("SELECT json_extract(index_json,?) value FROM runs WHERE project_id=? AND id=?", [anchorPath, scope.projectId, scope.runId])?.value;
24
+ if (rows.length > 1 || phases.length > 2 || rows.some(row => Buffer.byteLength(row.payload_json) > 96 * 1024 * 1024)
25
+ || phases.some(row => Buffer.byteLength(row.payload_json) > 16384) || !!rows.length !== !!anchor || !rows.length && phases.length)
26
+ pending("integration_custody_lost");
27
+ if (!rows.length)
28
+ return { operation: null, phases: [] };
29
+ let operation, intent, value;
30
+ try {
31
+ operation = JSON.parse(rows[0].payload_json);
32
+ intent = phases.map(row => JSON.parse(row.payload_json));
33
+ value = JSON.parse(anchor);
34
+ }
35
+ catch {
36
+ return pending("integration_custody_invalid");
37
+ }
38
+ if (operation?.plan?.schema_id !== "dd-flow/interactive-git-operation@1" || operation.plan.run_id !== scope.runId
39
+ || operation.identity !== hybridDigest(operation.plan) || value.identity !== operation.identity || hybridDigest(value.phases) !== hybridDigest(intent)
40
+ || new Set(intent.map(item => item.phase)).size !== intent.length
41
+ || intent.some(item => item.run_id !== scope.runId || item.identity !== operation.identity || !["commit", "merge"].includes(item.phase)
42
+ || typeof item.baseline !== "string" || item.baseline !== "" && !/^[a-f0-9]{40,64}$/.test(item.baseline))
43
+ || intent.length === 2 && (intent[0].phase !== "commit" || intent[1].phase !== "merge"))
44
+ pending("integration_custody_changed");
45
+ return { operation, phases: intent };
46
+ }
47
+ function gitReader(context, plan) {
48
+ const env = { ...Object.fromEntries(Object.entries(context.env).filter(([name]) => !name.startsWith("GIT_"))), GIT_OPTIONAL_LOCKS: "0", GIT_LITERAL_PATHSPECS: "1" };
49
+ return (args, root = plan.reviewed.policy.scope.workspaceRoot) => {
50
+ const result = spawnSync(plan.reviewed.policy.git_executable, ["-c", "core.fsmonitor=false", "-C", root, ...args], { env, timeout: 10000, maxBuffer: 16 * 1024 * 1024, shell: false, windowsHide: true });
51
+ if (result.error || result.signal || result.status !== 0)
52
+ pending("original_git_readback_failed");
53
+ return args.includes("-z") ? result.stdout.toString("utf8") : result.stdout.toString("utf8").trim();
54
+ };
55
+ }
56
+ function assertOwnedHead(context, operation, phases, head) {
57
+ const { plan } = operation, git = gitReader(context, plan), commit = phases.find(item => item.phase === "commit");
58
+ if (!commit) {
59
+ if (head !== (plan.reviewed.policy.policy.initial_head ?? ""))
60
+ pending("source_head_not_owned");
61
+ }
62
+ else if (head !== commit.baseline) {
63
+ const message = `chore(${plan.run_id.toLowerCase()}): integrate reviewed project changes`;
64
+ const paths = [...plan.inventory.references, ...plan.inventory.removals].map(ref => ref.path);
65
+ if (git(["show", "-s", "--format=%P%n%s", head]) !== `${commit.baseline}\n${message}`.trim()
66
+ || git(["diff-tree", "--root", "--no-commit-id", "--no-renames", "--name-only", "-r", "-z", head]).split("\0").filter(Boolean).some(path => !paths.includes(path)))
67
+ pending("scoped_commit_not_proven");
68
+ }
69
+ }
70
+ function selection(plan) {
71
+ const policy = plan.reviewed.policy;
72
+ return { projectRoot: policy.scope.projectRoot, workspaceRoot: policy.scope.workspaceRoot,
73
+ integrationBranch: policy.policy.integration_branch, gitExecutable: policy.git_executable };
74
+ }
75
+ function assertTool(bundle, plan) {
76
+ const actual = readInteractiveWorkGit(bundle), selected = plan.reviewed.policy;
77
+ if (actual.git_executable !== selected.git_executable || actual.git_sha256 !== selected.git_sha256)
78
+ refuse("selected_git_changed");
79
+ }
80
+ function integrationCustody(context, input) {
81
+ if (context.db.isOwnedWriteTransaction?.())
82
+ refuse("preparation_outside_writer_required");
83
+ const selected = readInteractiveStageScope(context, { ...input, stage: "integration" }, true), reviewed = prepareInteractiveReviewedGitInputs(context, input);
84
+ const state = stored(context, selected.scope), operation = state.operation;
85
+ if (!operation || hybridDigest(operation.plan.reviewed) !== hybridDigest(reviewed.original))
86
+ refuse("original_integration_operation_required");
87
+ const assertCustody = (writer) => {
88
+ reviewed.assertCustody(writer);
89
+ if (hybridDigest(stored(writer, selected.scope)) !== hybridDigest(state))
90
+ pending("integration_custody_changed");
91
+ };
92
+ return { selected, reviewed, state, operation, assertCustody };
93
+ }
94
+ /** Historical operation custody, not current Git observation. Prepare outside
95
+ * the writer; the returned fence itself reads only retained SQL/bundle data. */
96
+ export function prepareInteractiveIntegrationCustody(context, input) {
97
+ const retained = integrationCustody(context, input);
98
+ return { binding: { operation_sha256: retained.operation.identity, phases_sha256: hybridDigest(retained.state.phases) },
99
+ reviewed: structuredClone(retained.operation.plan.reviewed), assertCustody: retained.assertCustody };
100
+ }
101
+ /** Readback plus SQL custody for terminal acceptance. No dispatch or fresh
102
+ * operation selection; usable when a process died after the original effect. */
103
+ export function prepareInteractiveFinishIntegration(context, input) {
104
+ const { selected, reviewed, state, operation, assertCustody } = integrationCustody(context, input), { plan } = operation;
105
+ const observe = () => {
106
+ assertTool(selected.bundle, plan);
107
+ assertCustody(context);
108
+ if (plan.reviewed.empty) {
109
+ if (state.phases.length)
110
+ pending("empty_run_has_git_intent");
111
+ reviewed.observeBeforeDispatch().revalidate();
112
+ return { schema_id: "dd-flow/interactive-finish-integration@1", operation_sha256: operation.identity, no_op: true, git: null };
113
+ }
114
+ const git = observeReviewedProjectGitIntegration(selection(plan), context.env, plan.inventory.references, plan.inventory.removals);
115
+ if (git.repository !== plan.reviewed.policy.policy.repository || git.workspace_branch !== plan.reviewed.policy.policy.workspace_branch)
116
+ refuse("policy_repository_or_branch_changed");
117
+ assertOwnedHead(context, operation, state.phases, git.workspace_head);
118
+ const merge = state.phases.find(item => item.phase === "merge");
119
+ if (merge && git.integration_head !== git.workspace_head && git.integration_head !== merge.baseline
120
+ && gitReader(context, plan)(["show", "-s", "--format=%P", git.integration_head], plan.reviewed.policy.scope.projectRoot) !== `${merge.baseline} ${git.workspace_head}`)
121
+ pending("scoped_merge_not_proven");
122
+ reviewed.assertReviewedSources();
123
+ assertCustody(context);
124
+ return { schema_id: "dd-flow/interactive-finish-integration@1", operation_sha256: operation.identity, no_op: false, git };
125
+ };
126
+ const observation = observe();
127
+ return { binding: { operation_sha256: operation.identity, phases_sha256: hybridDigest(state.phases) },
128
+ reviewed: structuredClone(plan.reviewed), value: () => structuredClone(observation), assertCustody, revalidate: () => {
129
+ if (context.db.isOwnedWriteTransaction?.())
130
+ refuse("physical_revalidation_outside_writer_required");
131
+ if (hybridDigest(observe()) !== hybridDigest(observation))
132
+ pending("integration_readback_changed");
133
+ } };
134
+ }
135
+ /** Existing scoped Git executor, with original selected index bytes retained
136
+ * before effects and each phase anchored separately. Never push/stash/reset,
137
+ * repeat an unresolved phase or rediscover a new agreement after commit. */
138
+ export function integrateInteractiveFinish(context, input) {
139
+ if (context.db.isOwnedWriteTransaction?.())
140
+ refuse("preparation_outside_writer_required");
141
+ const selected = readInteractiveStageScope(context, { ...input, stage: "integration" }, true), { scope, bundle } = selected;
142
+ const reviewed = prepareInteractiveReviewedGitInputs(context, input);
143
+ const authority = (writer) => {
144
+ assertRunMutationAllowed(writer, scope.projectId, scope.runId);
145
+ const run = writer.db.get("SELECT status,index_json FROM runs WHERE project_id=? AND id=?", [scope.projectId, scope.runId]);
146
+ const index = JSON.parse(run.index_json);
147
+ if (run.status !== "running" || index.current_stage !== "integration"
148
+ || !index.stage_runs.some((stage) => stage.stage === "integration" && stage.status === "running"))
149
+ refuse("active_integration_required");
150
+ const stage = readDescriptorStageInput(writer, scope, bundle), actor = readActiveDescriptorStageCoordinator(writer, scope, bundle);
151
+ if (stage.input.predecessor?.stage !== "review" || hybridDigest(stage.input.predecessor.result) !== hybridDigest(reviewed.original.review_result))
152
+ refuse("accepted_review_predecessor_required");
153
+ if (writer.db.get("SELECT 1 FROM works WHERE project_id=? AND run_id=? AND parent_work_id=? AND status IN ('created','running','paused') LIMIT 1", [scope.projectId, scope.runId, actor.work_id]))
154
+ refuse("active_child_work");
155
+ return { stage, actor };
156
+ };
157
+ let state = stored(context, scope);
158
+ const replayed = Boolean(state.operation);
159
+ if (!state.operation) {
160
+ const owner = authority(context), admission = reviewed.prepareOriginalInputsAgreement(input.agreement);
161
+ const inventory = { references: reviewed.original.inventory.references, removals: reviewed.original.inventory.removals.map((ref) => {
162
+ const head = admission.original_inputs.find(item => item.path === ref.path).head;
163
+ if (!head)
164
+ return ref;
165
+ if (head.git_mode !== "100644" && head.git_mode !== "100755")
166
+ refuse("regular_original_removal_required");
167
+ return { ...ref, sha256: head.sha256, git_mode: head.git_mode };
168
+ }) };
169
+ const plan = { schema_id: "dd-flow/interactive-git-operation@1", run_id: input.runId, reviewed: reviewed.original, ...owner, inventory,
170
+ admission: { agreement: admission.agreement, original_inputs: admission.original_inputs.map(({ workspace, ...item }) => {
171
+ const { base64: _retainedWorkspaceBytes, ...reference } = workspace;
172
+ void _retainedWorkspaceBytes;
173
+ return { ...item, workspace: reference };
174
+ }) } };
175
+ const operation = { identity: hybridDigest(plan), plan };
176
+ if (Buffer.byteLength(JSON.stringify(operation)) > 96 * 1024 * 1024)
177
+ refuse("integration_operation_exceeds_bound");
178
+ const sources = reviewed.observeBeforeDispatch();
179
+ sources.revalidate();
180
+ context.db.writeTransaction(() => {
181
+ reviewed.assertCustody(context);
182
+ sources.assertCustody(context);
183
+ if (hybridDigest(authority(context)) !== hybridDigest(owner))
184
+ refuse("integration_authority_changed");
185
+ const current = stored(context, scope);
186
+ if (current.operation) {
187
+ if (current.operation.identity !== operation.identity)
188
+ pending("integration_operation_changed");
189
+ return;
190
+ }
191
+ appendAudit(context, { projectId: scope.projectId, eventType: event, payload: operation });
192
+ context.db.run("UPDATE runs SET index_json=json_set(index_json,?,json(?)) WHERE project_id=? AND id=?", [anchorPath, JSON.stringify({ identity: operation.identity, phases: [] }), scope.projectId, scope.runId]);
193
+ });
194
+ state = stored(context, scope);
195
+ }
196
+ const operation = state.operation, { plan } = operation;
197
+ if (hybridDigest(plan.reviewed) !== hybridDigest(reviewed.original))
198
+ refuse("accepted_integration_inputs_changed");
199
+ if (input.agreement !== undefined) {
200
+ const retained = plan.admission.agreement;
201
+ if (!retained || hybridDigest(input.agreement) !== hybridDigest({ basis: retained.basis, reason: retained.reason, include_existing_paths: retained.include_existing_paths }))
202
+ refuse("original_git_agreement_changed");
203
+ }
204
+ try {
205
+ const proof = prepareInteractiveFinishIntegration(context, input);
206
+ proof.revalidate();
207
+ return { integrated: true, replayed, integration: proof.value() };
208
+ }
209
+ catch (error) {
210
+ if (!(error instanceof AppError) || !["project_git_integration_invalid", "memory_git_integration_invalid"].includes(error.code))
211
+ throw error;
212
+ }
213
+ if (hybridDigest(authority(context)) !== hybridDigest({ stage: plan.stage, actor: plan.actor }))
214
+ refuse("integration_authority_changed");
215
+ if (state.phases.length) {
216
+ if (state.phases.length !== 1 || state.phases[0].phase !== "commit"
217
+ || plan.reviewed.policy.scope.projectRoot === plan.reviewed.policy.scope.workspaceRoot)
218
+ pending("original_git_phase_unresolved");
219
+ const head = gitReader(context, plan)(["rev-parse", "HEAD"]);
220
+ if (head === state.phases[0].baseline) {
221
+ // A selected index-only cleanup need not create a commit. Prove its
222
+ // source postcondition, then permit only the still-unissued merge.
223
+ try {
224
+ observeReviewedProjectGitIntegration({ ...selection(plan), projectRoot: plan.reviewed.policy.scope.workspaceRoot,
225
+ integrationBranch: plan.reviewed.policy.policy.workspace_branch }, context.env, plan.inventory.references, plan.inventory.removals);
226
+ }
227
+ catch {
228
+ pending("original_commit_not_proven");
229
+ }
230
+ }
231
+ assertOwnedHead(context, operation, state.phases, head);
232
+ }
233
+ else
234
+ reviewed.observeBeforeDispatch().revalidate();
235
+ let dispatched = false;
236
+ try {
237
+ integrateReviewedProjectFiles(selection(plan), context.env, plan.inventory.references, input.runId, (phase, baseline, action) => {
238
+ assertTool(bundle, plan);
239
+ const prior = stored(context, scope);
240
+ if (prior.phases.some(item => item.phase === phase))
241
+ pending("original_git_phase_unresolved");
242
+ const before = phase === "commit" ? reviewed.observeBeforeDispatch() : null;
243
+ if (before)
244
+ before.revalidate();
245
+ else
246
+ assertOwnedHead(context, operation, prior.phases, gitReader(context, plan)(["rev-parse", "HEAD"]));
247
+ context.db.writeTransaction(() => {
248
+ reviewed.assertCustody(context);
249
+ before?.assertCustody(context);
250
+ if (hybridDigest(authority(context)) !== hybridDigest({ stage: plan.stage, actor: plan.actor }))
251
+ refuse("integration_authority_changed");
252
+ const current = stored(context, scope);
253
+ if (current.operation?.identity !== operation.identity || current.phases.some(item => item.phase === phase)
254
+ || phase === "commit" && current.phases.length)
255
+ pending("original_git_phase_unresolved");
256
+ const intent = { identity: operation.identity, run_id: input.runId, phase, baseline };
257
+ appendAudit(context, { projectId: scope.projectId, eventType: phaseEvent, payload: intent });
258
+ context.db.run("UPDATE runs SET index_json=json_set(index_json,?,json(?)) WHERE project_id=? AND id=?", [anchorPath, JSON.stringify({ identity: operation.identity, phases: [...current.phases, intent] }), scope.projectId, scope.runId]);
259
+ });
260
+ dispatched = true;
261
+ // Intent recording is not permission to overwrite an index or task that
262
+ // changed during that writer. Recheck immediately before the effect.
263
+ before?.revalidate();
264
+ reviewed.assertCustody(context);
265
+ if (hybridDigest(authority(context)) !== hybridDigest({ stage: plan.stage, actor: plan.actor }))
266
+ refuse("integration_authority_changed");
267
+ action();
268
+ }, plan.reviewed.policy.policy, plan.inventory.removals);
269
+ const proof = prepareInteractiveFinishIntegration(context, input);
270
+ proof.revalidate();
271
+ return { integrated: true, replayed: false, integration: proof.value() };
272
+ }
273
+ catch (error) {
274
+ if (dispatched)
275
+ pending(error instanceof AppError ? error.code : "git_operation_failed");
276
+ throw error;
277
+ }
278
+ }
@@ -0,0 +1,82 @@
1
+ import { createHash } from "node:crypto";
2
+ import { AppError } from "../shared/errors.js";
3
+ import { hybridDigest } from "./hybrid-contracts.js";
4
+ import { assertPreparedRunBundleSqlAuthority } from "./runs.js";
5
+ import { readInteractiveUserInputs, readInteractiveInputDelivery, retainInteractiveUserInput } from "./interactive-user-inputs.js";
6
+ import { prepareRunControllerAnswer, answerRunController } from "./run-controller.js";
7
+ function refuse(reason) {
8
+ throw new AppError("interactive_input_delivery_invalid", "Interactive delivery requires its original input and exact controller pause", 2, { reason, effect: "no_effect" });
9
+ }
10
+ function pending(input, delivery) {
11
+ throw new AppError("interactive_input_delivery_pending", "User input is retained; reconcile its original controller answer without retargeting", 1, { effect: "committed", recoverable: true, request_id: input.requestId, delivery, next_action: "retry_exact_interactive_input" });
12
+ }
13
+ /** No new dispatcher. Retain the user bytes, then queue the ordinary HITL answer.
14
+ * The original pause target survives loss between those two existing owners. */
15
+ export function deliverInteractiveUserInput(context, scope, bundle, input) {
16
+ if (context.db.isOwnedWriteTransaction?.())
17
+ refuse("preparation_outside_writer_required");
18
+ const before = readInteractiveUserInputs(context, scope, bundle), prior = before.turns.find(turn => turn.request_id === input.requestId);
19
+ // Validate exact raw replay through its existing owner before observing or
20
+ // queuing any controller operation. Changed retry bytes are still no-effect.
21
+ if (prior)
22
+ retainInteractiveUserInput(context, scope, bundle, input);
23
+ const select = () => {
24
+ const run = assertPreparedRunBundleSqlAuthority(context, scope, bundle), index = JSON.parse(run.index_json);
25
+ if (bundle.execution.settings.execution !== "managed" || !context.db.get("SELECT 1 FROM sqlite_schema WHERE type='table' AND name='run_controllers'"))
26
+ return null;
27
+ const stage = index.stage_runs?.filter((item) => item.stage === index.current_stage).at(-1);
28
+ if (index.current_stage !== "interactive" || !stage || !["paused", "waiting_for_user"].includes(stage.status) || !stage.pause?.id)
29
+ return null;
30
+ const controllers = context.db.all("SELECT controller_id FROM run_controllers WHERE project_id=? AND run_id=? AND status NOT IN ('completed','cancelled','superseded')", [scope.projectId, scope.runId]);
31
+ if (controllers.length > 1)
32
+ refuse("ambiguous_controller");
33
+ const controller = controllers[0];
34
+ if (!controller)
35
+ return null;
36
+ // An already queued answer will wake the same coordinator, which must read
37
+ // the complete retained input set. Do not queue a second answer to its pause.
38
+ if (context.db.get("SELECT 1 FROM run_controller_operations WHERE controller_id=? AND json_extract(input_json,'$.action')='user_answer' AND json_extract(input_json,'$.pause_id')=?", [controller.controller_id, stage.pause.id]))
39
+ return null;
40
+ return { controller_id: controller.controller_id, pause_id: stage.pause.id,
41
+ request_id: `interactive-${hybridDigest({ run_id: scope.runId, request_id: input.requestId })}` };
42
+ };
43
+ const target = prior ? readInteractiveInputDelivery(context, scope, bundle, input.requestId) : context.db.readSnapshot(select);
44
+ let prepared;
45
+ const answer = target ? { projectRoot: input.projectRoot, runId: scope.runId, requestId: target.request_id,
46
+ pauseId: target.pause_id, answerFile: input.intakeFile } : null;
47
+ if (target && answer) {
48
+ try {
49
+ prepared = prepareRunControllerAnswer(context, answer);
50
+ const operationId = `${target.controller_id}:answer:${target.request_id}`;
51
+ if (prepared.operationId !== operationId)
52
+ refuse("original_controller_changed");
53
+ if (prepared.kind === "create") {
54
+ if (!prepared.bytes.equals(Buffer.from(input.bytes)))
55
+ refuse("prepared_user_bytes_changed");
56
+ }
57
+ else {
58
+ const operation = context.db.get("SELECT input_json FROM run_controller_operations WHERE operation_id=?", [operationId]);
59
+ const value = operation ? JSON.parse(operation.input_json) : null;
60
+ if (!value || value.action !== "user_answer" || value.pause_id !== target.pause_id
61
+ || value.sha256 !== createHash("sha256").update(input.bytes).digest("hex"))
62
+ refuse("original_answer_changed");
63
+ }
64
+ }
65
+ catch (error) {
66
+ if (prior)
67
+ return pending(input, target);
68
+ throw error;
69
+ }
70
+ }
71
+ const retained = retainInteractiveUserInput(context, scope, bundle, { requestId: input.requestId, kind: input.kind,
72
+ bytes: input.bytes, expectedInputsSha256: input.expectedInputsSha256, delivery: target });
73
+ if (!target || !answer || !prepared)
74
+ return { ...retained, delivery: { kind: "retained_only" } };
75
+ try {
76
+ const queued = answerRunController(context, answer, prepared);
77
+ return { ...retained, delivery: { kind: "controller_answer", pause_id: target.pause_id, ...queued } };
78
+ }
79
+ catch {
80
+ return pending(input, target);
81
+ }
82
+ }