@deksden-com/dd-flow-cli 0.9.0-beta.134 → 0.9.0-beta.136

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 (161) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +33 -0
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/command-inputs.js +1 -0
  5. package/dist/cli/command-metadata.js +144 -0
  6. package/dist/cli/command-option-descriptions.js +186 -0
  7. package/dist/cli/completion-cli.js +70 -0
  8. package/dist/cli/completion.js +57 -0
  9. package/dist/cli/error-output.js +52 -0
  10. package/dist/cli/executor-onboarding.js +71 -0
  11. package/dist/cli/harnesses.js +84 -0
  12. package/dist/cli/models.js +39 -0
  13. package/dist/cli/operator-profiles.js +61 -0
  14. package/dist/cli/output-options.js +35 -0
  15. package/dist/cli/response-file.js +69 -0
  16. package/dist/cli/run-cli.js +5 -0
  17. package/dist/cli/setup.js +147 -0
  18. package/dist/harness-runtime/OPENCODE-LICENSE.txt +24 -0
  19. package/dist/harness-runtime/bin/dd-opencode.mjs +1 -1
  20. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +13 -0
  21. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +193 -39
  22. package/dist/harness-runtime/lib/dd-opencode.mjs +295 -100
  23. package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
  24. package/dist/harness-runtime/lib/managed-daemon.mjs +1 -1
  25. package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
  26. package/dist/harness-runtime/lib/native-hook-command.mjs +4 -4
  27. package/dist/harness-runtime/lib/opencode-plugin.mjs +60 -0
  28. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-1y4a697q.js +81 -0
  29. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-2ny4vxct.js +92 -0
  30. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-6mptkzbc.js +0 -0
  31. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-7x67cf1q.js +52 -0
  32. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-9rqn6x4v.js +16 -0
  33. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-9vdz0zwn.js +1661 -0
  34. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ecs5ftka.js +1466 -0
  35. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-frbwqjmf.js +145 -0
  36. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-gne7h7mq.js +50 -0
  37. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-gx9wnkzr.js +105 -0
  38. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-m2eywc8w.js +0 -0
  39. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-nwcpakzx.js +12 -0
  40. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-q4g3afnw.js +623 -0
  41. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-qav6mfdz.js +0 -0
  42. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-sxt88q69.js +12 -0
  43. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-t1xa31vm.js +39 -0
  44. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ve4aws36.js +103 -0
  45. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vmytr4q8.js +4 -0
  46. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vt9jbch8.js +36 -0
  47. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vyas3vgt.js +0 -0
  48. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-wjyeq25y.js +30 -0
  49. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-xgnj2w4v.js +20 -0
  50. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-y2etg5bs.js +9 -0
  51. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ydhz1zrh.js +238 -0
  52. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-z6qdwcsf.js +62 -0
  53. package/dist/harness-runtime/vendor/opencode-client/contract.d.ts +1 -0
  54. package/dist/harness-runtime/vendor/opencode-client/contract.js +12 -0
  55. package/dist/harness-runtime/vendor/opencode-client/effect/api/api.d.ts +2806 -0
  56. package/dist/harness-runtime/vendor/opencode-client/effect/api/api.js +0 -0
  57. package/dist/harness-runtime/vendor/opencode-client/effect/api.d.ts +4 -0
  58. package/dist/harness-runtime/vendor/opencode-client/effect/api.js +0 -0
  59. package/dist/harness-runtime/vendor/opencode-client/effect/client.d.ts +2530 -0
  60. package/dist/harness-runtime/vendor/opencode-client/effect/client.js +15 -0
  61. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client-error.d.ts +7 -0
  62. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client-error.js +6 -0
  63. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client.d.ts +2529 -0
  64. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client.js +8 -0
  65. package/dist/harness-runtime/vendor/opencode-client/effect/generated/index.d.ts +2 -0
  66. package/dist/harness-runtime/vendor/opencode-client/effect/generated/index.js +12 -0
  67. package/dist/harness-runtime/vendor/opencode-client/effect/index.d.ts +35 -0
  68. package/dist/harness-runtime/vendor/opencode-client/effect/index.js +75 -0
  69. package/dist/harness-runtime/vendor/opencode-client/effect/rpc.d.ts +20 -0
  70. package/dist/harness-runtime/vendor/opencode-client/effect/rpc.js +11 -0
  71. package/dist/harness-runtime/vendor/opencode-client/effect/service.d.ts +90 -0
  72. package/dist/harness-runtime/vendor/opencode-client/effect/service.js +187 -0
  73. package/dist/harness-runtime/vendor/opencode-client/package.json +84 -0
  74. package/dist/harness-runtime/vendor/opencode-client/promise/api.d.ts +21 -0
  75. package/dist/harness-runtime/vendor/opencode-client/promise/api.js +0 -0
  76. package/dist/harness-runtime/vendor/opencode-client/promise/client.d.ts +252 -0
  77. package/dist/harness-runtime/vendor/opencode-client/promise/client.js +14 -0
  78. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client-error.d.ts +8 -0
  79. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client-error.js +6 -0
  80. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client.d.ts +259 -0
  81. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client.js +7 -0
  82. package/dist/harness-runtime/vendor/opencode-client/promise/generated/index.d.ts +3 -0
  83. package/dist/harness-runtime/vendor/opencode-client/promise/generated/index.js +79 -0
  84. package/dist/harness-runtime/vendor/opencode-client/promise/generated/types.d.ts +8932 -0
  85. package/dist/harness-runtime/vendor/opencode-client/promise/generated/types.js +70 -0
  86. package/dist/harness-runtime/vendor/opencode-client/promise/index.d.ts +6 -0
  87. package/dist/harness-runtime/vendor/opencode-client/promise/index.js +83 -0
  88. package/dist/harness-runtime/vendor/opencode-client/promise/rpc.d.ts +32 -0
  89. package/dist/harness-runtime/vendor/opencode-client/promise/rpc.js +7 -0
  90. package/dist/harness-runtime/vendor/opencode-client/promise/service.d.ts +24 -0
  91. package/dist/harness-runtime/vendor/opencode-client/promise/service.js +187 -0
  92. package/dist/harness-runtime/vendor/opencode-client/pty-handoff.d.ts +12 -0
  93. package/dist/harness-runtime/vendor/opencode-client/pty-handoff.js +14 -0
  94. package/dist/harness-runtime/vendor/opencode-client/rpc-runtime.d.ts +20 -0
  95. package/dist/harness-runtime/vendor/opencode-client/rpc-runtime.js +14 -0
  96. package/dist/harness-runtime/vendor/opencode-client/service-contender.d.ts +37 -0
  97. package/dist/harness-runtime/vendor/opencode-client/service-contender.js +10 -0
  98. package/dist/harness-runtime/vendor/opencode-client/service-probe.d.ts +70 -0
  99. package/dist/harness-runtime/vendor/opencode-client/service-probe.js +16 -0
  100. package/dist/harness-runtime/vendor/opencode-client/service-timing.d.ts +12 -0
  101. package/dist/harness-runtime/vendor/opencode-client/service-timing.js +10 -0
  102. package/dist/harness-runtime/vendor/opencode-client/service-version.d.ts +2 -0
  103. package/dist/harness-runtime/vendor/opencode-client/service-version.js +6 -0
  104. package/dist/harness-runtime/vendor/opencode-client/service.d.ts +52 -0
  105. package/dist/harness-runtime/vendor/opencode-client/service.js +1 -0
  106. package/dist/harness-runtime/vendor/opencode-client/shared-events.d.ts +11 -0
  107. package/dist/harness-runtime/vendor/opencode-client/shared-events.js +8 -0
  108. package/dist/harness-runtime/vendor/opencode-client/solid/connection.d.ts +37 -0
  109. package/dist/harness-runtime/vendor/opencode-client/solid/connection.js +10 -0
  110. package/dist/harness-runtime/vendor/opencode-client/solid/data.d.ts +211 -0
  111. package/dist/harness-runtime/vendor/opencode-client/solid/data.js +18 -0
  112. package/dist/harness-runtime/vendor/opencode-client/solid/index.d.ts +3 -0
  113. package/dist/harness-runtime/vendor/opencode-client/solid/index.js +32 -0
  114. package/dist/harness-runtime/vendor/opencode-client/solid/pty.d.ts +22 -0
  115. package/dist/harness-runtime/vendor/opencode-client/solid/pty.js +8 -0
  116. package/dist/harness-runtime/vendor/opencode-plugin.mjs +3 -0
  117. package/dist/services/cli-operation-classifier.js +4 -0
  118. package/dist/services/cli-upgrade-allowlist.js +27 -0
  119. package/dist/services/discovery/agy-discovery.js +2 -0
  120. package/dist/services/discovery/codex-discovery.js +2 -0
  121. package/dist/services/discovery/droid-discovery.js +2 -0
  122. package/dist/services/discovery/grok-discovery.js +2 -0
  123. package/dist/services/discovery/opencode-discovery.js +2 -0
  124. package/dist/services/discovery/static.js +31 -0
  125. package/dist/services/discovery/zcode-discovery.js +2 -0
  126. package/dist/services/engines.js +23 -25
  127. package/dist/services/eval-snapshots.js +1 -1
  128. package/dist/services/executor-onboarding.js +56 -0
  129. package/dist/services/external-work-launch.js +1 -1
  130. package/dist/services/harness-discovery.js +29 -0
  131. package/dist/services/harness-doctor.js +75 -0
  132. package/dist/services/harness-probe-effects.js +51 -0
  133. package/dist/services/harness-version-probe.js +100 -0
  134. package/dist/services/hooks.js +1 -1
  135. package/dist/services/hybrid-contracts.js +455 -0
  136. package/dist/services/installation-lock.js +92 -0
  137. package/dist/services/managed-processes.js +30 -0
  138. package/dist/services/merge-server.js +1 -1
  139. package/dist/services/model-catalog.js +109 -0
  140. package/dist/services/model-discovery.js +46 -0
  141. package/dist/services/operator-profiles.js +236 -0
  142. package/dist/services/release-archive-producer.js +52 -0
  143. package/dist/services/release-archive.js +143 -0
  144. package/dist/services/release-cache.js +142 -0
  145. package/dist/services/release-compatibility.js +18 -0
  146. package/dist/services/release-download.js +67 -0
  147. package/dist/services/release-envelope.js +104 -0
  148. package/dist/services/release-manifest.js +45 -0
  149. package/dist/services/release-payload.js +114 -0
  150. package/dist/services/release-producer.js +63 -0
  151. package/dist/services/release-provenance.js +160 -0
  152. package/dist/services/release-registry.js +66 -0
  153. package/dist/services/release-source.js +194 -0
  154. package/dist/services/run-controller-recovery.js +2 -2
  155. package/dist/services/run-controller.js +3 -2
  156. package/dist/services/run-health.js +149 -0
  157. package/dist/services/runs.js +61 -13
  158. package/dist/services/setup.js +377 -0
  159. package/dist/storage/database.js +18 -3
  160. package/dist/storage/writer-contract.js +8 -0
  161. package/package.json +4 -1
@@ -0,0 +1,160 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { readBoundedReleaseFile } from "./release-source.js";
7
+ import { inspectReleaseArchive } from "./release-archive.js";
8
+ import { produceReleaseArchive } from "./release-archive-producer.js";
9
+ function refuse(reason) {
10
+ throw new AppError("release_provenance", "Producer source provenance cannot be verified", 1, { reason, phase: "release_production", effect: "no_effect" });
11
+ }
12
+ function gitBytes(directory, args, maxBuffer = 1024 * 1024, input) {
13
+ const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null" };
14
+ for (const name of Object.keys(env))
15
+ if (name.startsWith("GIT_") && !["GIT_OPTIONAL_LOCKS", "GIT_CONFIG_NOSYSTEM", "GIT_CONFIG_GLOBAL"].includes(name))
16
+ delete env[name];
17
+ const result = spawnSync("git", ["-c", "core.fsmonitor=false", "-c", "core.untrackedCache=false", ...args], {
18
+ cwd: directory, env, timeout: 10_000, maxBuffer, input
19
+ });
20
+ if (result.status !== 0 || result.error)
21
+ refuse("git_source_unavailable");
22
+ return result.stdout;
23
+ }
24
+ /** Read-only source/tag verification. No fetch, tag creation, commit or cleanup. */
25
+ export function verifyProducerSource(root, source, version) {
26
+ if (!source || !/^[a-f0-9]{40}$/.test(source.commit) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(source.repository)
27
+ || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(version)
28
+ || (source.kind !== "source_checkout" && source.kind !== "published_release")
29
+ || (source.kind === "source_checkout" ? source.tag !== null : source.tag !== `v${version}`))
30
+ refuse("invalid_source_identity");
31
+ let directory;
32
+ try {
33
+ directory = fs.realpathSync(root);
34
+ }
35
+ catch {
36
+ return refuse("source_unavailable");
37
+ }
38
+ const git = (args) => gitBytes(directory, args).toString("utf8").trim();
39
+ const top = git(["rev-parse", "--show-toplevel"]);
40
+ try {
41
+ if (fs.realpathSync(top) !== directory)
42
+ refuse("source_root_mismatch");
43
+ }
44
+ catch (error) {
45
+ if (error instanceof AppError)
46
+ throw error;
47
+ return refuse("source_root_mismatch");
48
+ }
49
+ const origin = git(["remote", "get-url", "origin"]);
50
+ if (![`https://github.com/${source.repository}`, `https://github.com/${source.repository}.git`, `git@github.com:${source.repository}.git`, `ssh://git@github.com/${source.repository}.git`].includes(origin))
51
+ refuse("source_repository_mismatch");
52
+ if (git(["status", "--porcelain", "--untracked-files=all"]))
53
+ refuse("source_not_clean");
54
+ const commit = git(["rev-parse", "HEAD"]), tree = git(["rev-parse", "HEAD^{tree}"]);
55
+ if (commit !== source.commit)
56
+ refuse("source_commit_mismatch");
57
+ if (readBoundedReleaseFile(path.join(directory, "VERSION"), 1024).toString("utf8").trim() !== version)
58
+ refuse("source_version_mismatch");
59
+ if (source.tag && git(["rev-parse", "--verify", `refs/tags/${source.tag}^{commit}`]) !== commit)
60
+ refuse("source_tag_mismatch");
61
+ return { commit, tree, version };
62
+ }
63
+ /** Exact committed release tree, with only the four canon directory aliases
64
+ * materialized as regular files. No mutable worktree reads or export filters.
65
+ */
66
+ function committedBlobs(root, commit) {
67
+ if (!/^[a-f0-9]{40}$/.test(commit))
68
+ refuse("invalid_source_identity");
69
+ const blobs = new Map();
70
+ const links = [];
71
+ const listing = gitBytes(root, ["ls-tree", "-rz", "--full-tree", commit], 16 * 1024 * 1024).toString("utf8");
72
+ for (const record of listing.split("\0").filter(Boolean)) {
73
+ const match = /^(100644|100755|120000) blob ([a-f0-9]{40})\t(.+)$/.exec(record);
74
+ if (!match)
75
+ refuse("unsupported_committed_tree_entry");
76
+ const mode = match[1], hash = match[2], name = match[3];
77
+ if (mode === "120000")
78
+ links.push({ name, hash });
79
+ else
80
+ blobs.set(name, { hash, executable: mode === "100755" });
81
+ }
82
+ const aliases = { code: "mb-sdlc/code", plan: "mb-sdlc/plan", merge: "mb-sdlc/merge", specify: "mb-sdlc/specify" };
83
+ for (const link of links) {
84
+ const match = /^(\.memory-bank\/)?dd-flow\/(code|plan|merge|specify)$/.exec(link.name);
85
+ if (!match)
86
+ refuse("unreviewed_committed_alias");
87
+ const target = gitBytes(root, ["cat-file", "blob", link.hash]).toString("utf8");
88
+ if (target !== aliases[match[2]])
89
+ refuse("unreviewed_committed_alias");
90
+ const targetPrefix = `${path.posix.dirname(link.name)}/${target}/`;
91
+ const children = [...blobs].filter(([name]) => name.startsWith(targetPrefix));
92
+ if (!children.length)
93
+ refuse("empty_committed_alias");
94
+ for (const [name, blob] of children) {
95
+ const alias = `${link.name}/${name.slice(targetPrefix.length)}`;
96
+ if (blobs.has(alias))
97
+ refuse("committed_alias_collision");
98
+ blobs.set(alias, blob);
99
+ }
100
+ }
101
+ return blobs;
102
+ }
103
+ export function verifyCanonArchiveSource(root, commit, identity, compressed) {
104
+ const blobs = committedBlobs(root, commit), entries = inspectReleaseArchive(identity, compressed);
105
+ const roots = new Set(entries.map(entry => entry.path.split("/")[0]));
106
+ if (roots.size !== 1)
107
+ refuse("canon_archive_root_ambiguous");
108
+ const prefix = `${[...roots][0]}/`;
109
+ const directories = new Set();
110
+ for (const name of blobs.keys()) {
111
+ const parts = name.split("/");
112
+ for (let index = 1; index < parts.length; index++)
113
+ directories.add(parts.slice(0, index).join("/"));
114
+ }
115
+ const seen = new Set();
116
+ for (const entry of entries) {
117
+ if (entry.path === prefix.slice(0, -1) && entry.kind === "directory")
118
+ continue;
119
+ const name = entry.path.slice(prefix.length);
120
+ if (entry.kind === "directory") {
121
+ if (!directories.has(name))
122
+ refuse("canon_archive_extra_entry");
123
+ continue;
124
+ }
125
+ const blob = blobs.get(name);
126
+ if (!blob)
127
+ refuse("canon_archive_extra_entry");
128
+ const hash = createHash("sha1").update(`blob ${entry.bytes.length}\0`).update(entry.bytes).digest("hex");
129
+ if (hash !== blob.hash || Boolean(entry.mode & 0o111) !== blob.executable)
130
+ refuse("canon_archive_blob_mismatch");
131
+ seen.add(name);
132
+ }
133
+ if (seen.size !== blobs.size)
134
+ refuse("canon_archive_missing_entry");
135
+ }
136
+ /** Produce from immutable Git objects in one bounded batch, not worktree files. */
137
+ export function produceCanonArchive(root, commit) {
138
+ const blobs = committedBlobs(root, commit), hashes = [...new Set([...blobs.values()].map(blob => blob.hash))];
139
+ const batch = gitBytes(root, ["cat-file", "--batch"], 512 * 1024 * 1024, Buffer.from(hashes.join("\n") + "\n"));
140
+ const contents = new Map();
141
+ let offset = 0;
142
+ for (const hash of hashes) {
143
+ const newline = batch.indexOf(10, offset);
144
+ if (newline < 0)
145
+ refuse("invalid_git_blob_batch");
146
+ const match = /^([a-f0-9]{40}) blob ([0-9]+)$/.exec(batch.subarray(offset, newline).toString("ascii"));
147
+ if (!match || match[1] !== hash)
148
+ refuse("invalid_git_blob_batch");
149
+ const size = Number(match[2]), end = newline + 1 + size;
150
+ if (!Number.isSafeInteger(size) || end >= batch.length || batch[end] !== 10)
151
+ refuse("invalid_git_blob_batch");
152
+ contents.set(hash, batch.subarray(newline + 1, end));
153
+ offset = end + 1;
154
+ }
155
+ if (offset !== batch.length)
156
+ refuse("invalid_git_blob_batch");
157
+ const bytes = produceReleaseArchive([...blobs].map(([name, blob]) => ({ path: `canon/${name}`, bytes: contents.get(blob.hash), executable: blob.executable })));
158
+ verifyCanonArchiveSource(root, commit, { url: "https://producer.invalid/canon.tgz", bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") }, bytes);
159
+ return bytes;
160
+ }
@@ -0,0 +1,66 @@
1
+ import { valid, prerelease } from "semver";
2
+ import { AppError } from "../shared/errors.js";
3
+ import { readBoundedReleaseFile } from "./release-source.js";
4
+ function refuse(reason) {
5
+ throw new AppError("release_registry", "Release selection cannot be resolved", 1, { reason, phase: "release_selection", effect: "no_effect", help: "setup" });
6
+ }
7
+ function object(value, keys, required = keys) {
8
+ if (!value || typeof value !== "object" || Array.isArray(value)
9
+ || required.some(key => !Object.hasOwn(value, key))
10
+ || Object.keys(value).some(key => !keys.includes(key)))
11
+ refuse("invalid_registry_shape");
12
+ return value;
13
+ }
14
+ /** Operator registry is a selector, not signing authority or qualification evidence. */
15
+ export function selectRegistryRelease(input, channel, version) {
16
+ if (!["stable", "preview", "development"].includes(channel))
17
+ refuse("invalid_selected_channel");
18
+ const registry = object(input, ["schema_id", "releases", "defaults"]);
19
+ if (registry.schema_id !== "dd-flow/release-registry@1" || !Array.isArray(registry.releases)
20
+ || registry.releases.length < 1 || registry.releases.length > 10_000)
21
+ refuse("invalid_registry_shape");
22
+ const defaults = object(registry.defaults, ["stable", "preview", "development"], []);
23
+ const entries = new Map();
24
+ for (const value of registry.releases) {
25
+ const entry = object(value, ["channel", "version", "envelope_url"]);
26
+ if (!["stable", "preview", "development"].includes(String(entry.channel))
27
+ || typeof entry.version !== "string" || valid(entry.version) !== entry.version
28
+ || (entry.channel === "stable" && prerelease(entry.version)) || typeof entry.envelope_url !== "string")
29
+ refuse("invalid_registry_entry");
30
+ let url;
31
+ try {
32
+ url = new URL(entry.envelope_url);
33
+ }
34
+ catch {
35
+ return refuse("invalid_registry_url");
36
+ }
37
+ if (url.protocol !== "https:" || url.username || url.password || url.hash || (url.port && url.port !== "443"))
38
+ refuse("invalid_registry_url");
39
+ const key = `${entry.channel}/${entry.version}`;
40
+ if (entries.has(key))
41
+ refuse("duplicate_registry_selection");
42
+ entries.set(key, { channel: entry.channel, version: entry.version, envelope_url: entry.envelope_url });
43
+ }
44
+ for (const [key, selected] of Object.entries(defaults)) {
45
+ if (typeof selected !== "string" || !entries.has(`${key}/${selected}`))
46
+ refuse("invalid_registry_default");
47
+ }
48
+ const selected = version ?? defaults[channel];
49
+ if (typeof selected !== "string" || valid(selected) !== selected)
50
+ refuse("missing_or_invalid_release_version");
51
+ const entry = entries.get(`${channel}/${selected}`);
52
+ if (!entry)
53
+ refuse("release_not_in_selected_channel");
54
+ return { ...entry };
55
+ }
56
+ export function readRegistryRelease(file, channel, version) {
57
+ const bytes = readBoundedReleaseFile(file, 4 * 1024 * 1024);
58
+ let input;
59
+ try {
60
+ input = JSON.parse(bytes.toString("utf8"));
61
+ }
62
+ catch {
63
+ return refuse("invalid_registry_json");
64
+ }
65
+ return selectRegistryRelease(input, channel, version);
66
+ }
@@ -0,0 +1,194 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createPublicKey } from "node:crypto";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { authenticateReleaseEnvelope, releaseProducerKeyId } from "./release-envelope.js";
6
+ import { verifyReleaseArchive } from "./release-manifest.js";
7
+ import { downloadReleaseArchive } from "./release-download.js";
8
+ function refuse(reason) {
9
+ throw new AppError("release_source", "Release source cannot be read", 1, { reason, phase: "release_source", effect: "no_effect", help: "setup" });
10
+ }
11
+ /** Bounded regular-file reads; paths, parser excerpts and key material never enter diagnostics. */
12
+ export function readBoundedReleaseFile(file, maxBytes) {
13
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
14
+ refuse("invalid_source_limit");
15
+ let fd;
16
+ try {
17
+ if (!fs.lstatSync(file).isFile())
18
+ refuse("source_not_regular_file");
19
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
20
+ }
21
+ catch (error) {
22
+ if (error instanceof AppError)
23
+ throw error;
24
+ return refuse("source_unavailable");
25
+ }
26
+ try {
27
+ const stat = fs.fstatSync(fd);
28
+ if (!stat.isFile() || stat.size < 1 || stat.size > maxBytes)
29
+ refuse("source_size_outside_limit");
30
+ const bytes = Buffer.alloc(stat.size);
31
+ let offset = 0;
32
+ while (offset < bytes.length) {
33
+ const received = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
34
+ if (!received)
35
+ refuse("source_changed_during_read");
36
+ offset += received;
37
+ }
38
+ if (fs.readSync(fd, Buffer.alloc(1), 0, 1, offset) !== 0)
39
+ refuse("source_changed_during_read");
40
+ return bytes;
41
+ }
42
+ catch (error) {
43
+ if (error instanceof AppError)
44
+ throw error;
45
+ return refuse("source_unavailable");
46
+ }
47
+ finally {
48
+ fs.closeSync(fd);
49
+ }
50
+ }
51
+ function json(bytes) {
52
+ try {
53
+ return JSON.parse(bytes.toString("utf8"));
54
+ }
55
+ catch {
56
+ return refuse("invalid_source_json");
57
+ }
58
+ }
59
+ /** Explicit operator trust input is separate from the release bundle. No implicit key enrollment. */
60
+ export function readTrustedReleaseProducers(file) {
61
+ const input = json(readBoundedReleaseFile(file, 1024 * 1024));
62
+ if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).length !== 2 || Object.keys(input).some(key => !["producers", "schema_id"].includes(key)))
63
+ refuse("invalid_trust_shape");
64
+ const settings = input;
65
+ if (settings.schema_id !== "dd-flow/release-trust@1" || !Array.isArray(settings.producers) || settings.producers.length < 1 || settings.producers.length > 100)
66
+ refuse("invalid_trust_shape");
67
+ const result = new Map();
68
+ for (const input of settings.producers) {
69
+ if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).length !== 3 || Object.keys(input).some(key => !["channels", "public_key_pem", "repository"].includes(key)))
70
+ refuse("invalid_producer_shape");
71
+ const producer = input;
72
+ if (typeof producer.public_key_pem !== "string" || !/^-----BEGIN PUBLIC KEY-----\r?\n[A-Za-z0-9+/=\r\n]+-----END PUBLIC KEY-----\r?\n?$/.test(producer.public_key_pem) ||
73
+ typeof producer.repository !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(producer.repository) ||
74
+ !Array.isArray(producer.channels) || producer.channels.length < 1 || producer.channels.length > 3 ||
75
+ producer.channels.some(channel => !["stable", "preview", "development"].includes(channel)) || new Set(producer.channels).size !== producer.channels.length)
76
+ refuse("invalid_producer_scope");
77
+ try {
78
+ const publicKey = createPublicKey(producer.public_key_pem), id = releaseProducerKeyId(publicKey);
79
+ if (result.has(id))
80
+ refuse("duplicate_producer_key");
81
+ result.set(id, { publicKey, repository: producer.repository, channels: new Set(producer.channels) });
82
+ }
83
+ catch (error) {
84
+ if (error instanceof AppError)
85
+ throw error;
86
+ return refuse("invalid_producer_key");
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+ /** Authenticate metadata before reading archives; all integrity checks precede installation writes. */
92
+ export function readLocalReleaseBundle(root, producers, compatibility) {
93
+ try {
94
+ if (!fs.lstatSync(root).isDirectory())
95
+ refuse("source_not_directory");
96
+ }
97
+ catch (error) {
98
+ if (error instanceof AppError)
99
+ throw error;
100
+ return refuse("source_unavailable");
101
+ }
102
+ const envelope = json(readBoundedReleaseFile(path.join(root, "release-envelope.json"), 24 * 1024 * 1024));
103
+ const authenticated = authenticateReleaseEnvelope(envelope, producers, compatibility);
104
+ const canonArchive = readBoundedReleaseFile(path.join(root, "canon.tgz"), Math.min(authenticated.manifest.canon.archive.bytes, 256 * 1024 * 1024));
105
+ const engineArchive = readBoundedReleaseFile(path.join(root, "engine.tgz"), Math.min(authenticated.manifest.engine.archive.bytes, 256 * 1024 * 1024));
106
+ verifyReleaseArchive(authenticated.manifest.canon.archive, canonArchive);
107
+ verifyReleaseArchive(authenticated.manifest.engine.archive, engineArchive);
108
+ return { envelope, authenticated, canonArchive, engineArchive };
109
+ }
110
+ function remoteUrl(input) {
111
+ let url;
112
+ try {
113
+ url = new URL(input);
114
+ }
115
+ catch {
116
+ return refuse("invalid_remote_source");
117
+ }
118
+ // The explicit source selects a transport origin, never a signing key.
119
+ if (url.protocol !== "https:" || url.username || url.password || url.hash || (url.port && url.port !== "443"))
120
+ refuse("unsafe_remote_source");
121
+ return url;
122
+ }
123
+ /** Remote metadata is untrusted until signature verification; archives are fetched only afterward.
124
+ * Redirects, cross-origin artifacts and implicit credential forwarding are never admitted.
125
+ */
126
+ export async function readRemoteReleaseBundle(source, producers, compatibility, options = {}) {
127
+ const url = remoteUrl(source), timeoutMs = options.timeoutMs ?? 60_000;
128
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000)
129
+ refuse("invalid_source_timeout");
130
+ const fetcher = options.fetch ?? globalThis.fetch;
131
+ let response;
132
+ try {
133
+ response = await fetcher(url, { redirect: "error", signal: AbortSignal.timeout(timeoutMs), credentials: "omit" });
134
+ }
135
+ catch {
136
+ return refuse("remote_source_unavailable_or_redirect");
137
+ }
138
+ if (!response.ok || !response.body) {
139
+ await response.body?.cancel();
140
+ return refuse("remote_source_http_failure");
141
+ }
142
+ const maxBytes = 24 * 1024 * 1024, length = response.headers.get("content-length");
143
+ if (length !== null && (!/^[0-9]+$/.test(length) || Number(length) < 1 || Number(length) > maxBytes)) {
144
+ await response.body.cancel();
145
+ return refuse("remote_source_size_outside_limit");
146
+ }
147
+ const reader = response.body.getReader(), chunks = [];
148
+ let received = 0;
149
+ try {
150
+ while (true) {
151
+ const { done, value } = await reader.read();
152
+ if (done)
153
+ break;
154
+ received += value.byteLength;
155
+ if (received > maxBytes) {
156
+ await reader.cancel();
157
+ return refuse("remote_source_size_outside_limit");
158
+ }
159
+ chunks.push(Buffer.from(value));
160
+ }
161
+ }
162
+ catch (error) {
163
+ if (error instanceof AppError)
164
+ throw error;
165
+ return refuse("remote_source_interrupted");
166
+ }
167
+ finally {
168
+ reader.releaseLock();
169
+ }
170
+ if (!received || (length !== null && Number(length) !== received))
171
+ refuse("remote_source_length_mismatch");
172
+ const envelope = json(Buffer.concat(chunks, received));
173
+ const authenticated = authenticateReleaseEnvelope(envelope, producers, compatibility);
174
+ if (options.expectedVersion !== undefined && authenticated.manifest.version !== options.expectedVersion)
175
+ refuse("selected_version_mismatch");
176
+ for (const artifact of [authenticated.manifest.canon.archive, authenticated.manifest.engine.archive]) {
177
+ if (remoteUrl(artifact.url).origin !== url.origin)
178
+ refuse("cross_origin_artifact_refused");
179
+ }
180
+ const archives = [];
181
+ for (const artifact of ["canon", "engine"]) {
182
+ archives.push(await downloadReleaseArchive(authenticated.manifest[artifact].archive, {
183
+ allowedHosts: new Set([url.hostname]), timeoutMs, fetch: fetcher,
184
+ ...(options.onProgress ? { onProgress: progress => options.onProgress({ artifact, ...progress }) } : {})
185
+ }));
186
+ }
187
+ return { envelope, authenticated, canonArchive: archives[0], engineArchive: archives[1] };
188
+ }
189
+ /** One source selector for setup/update; local bundles remain fully offline. */
190
+ export async function readReleaseBundle(source, producers, compatibility, options = {}) {
191
+ if (/^https?:\/\//i.test(source))
192
+ return readRemoteReleaseBundle(source, producers, compatibility, options);
193
+ return readLocalReleaseBundle(source, producers, compatibility);
194
+ }
@@ -211,10 +211,10 @@ export async function resumeControllerSession(context, row, manifest, state, ass
211
211
  receipt = await sendAck(dispatch.operation_id, dispatch.prompt);
212
212
  }
213
213
  catch (error) {
214
- if (runtimeHarness(session.profile) !== "codex-desktop")
214
+ if (!["codex-desktop", "opencode-server"].includes(runtimeHarness(session.profile)))
215
215
  throw error;
216
216
  const continuationText = `Continue the same retained Session and recovery generation. First inspect the current RUN recovery state; only if the exact ACK still awaits acceptance, execute the exact standalone acceptance command below once. Never deliver it again after acceptance. Then stop at the original Stage or Work-graph boundary.\n\n${dispatch.prompt}`;
217
- const inspect = (args, operationId) => runHarnessAdapter({ executable: manifest.adapters["codex-desktop"].executable,
217
+ const inspect = (args, operationId) => runHarnessAdapter({ executable: manifest.adapters[runtimeHarness(session.profile)].executable,
218
218
  args, env: controllerEnvironment(executionContext, manifest, session), runtimeOwner: session.owner, operationId, timeoutMs: 30_000 });
219
219
  const resumed = await continueCodexCapacity({ error, sessionId: session.id, operationId: dispatch.operation_id, allowSettledDescendants: true, continuationText,
220
220
  retainedIntents: () => {
@@ -578,12 +578,12 @@ async function executeController(context, row, manifest, state, assertOwnership,
578
578
  receipt = await send(text, firstOperationId);
579
579
  }
580
580
  catch (error) {
581
- if (runtimeHarness(session.profile) !== "codex-desktop")
581
+ if (!["codex-desktop", "opencode-server"].includes(runtimeHarness(session.profile)))
582
582
  throw error;
583
583
  const resumeCommand = hitl ? text.split("\n\n")[1] : undefined;
584
584
  if (hitl && !resumeCommand?.includes(" stage resume "))
585
585
  throw error;
586
- const inspect = (args, id) => runHarnessAdapter({ executable: manifest.adapters["codex-desktop"].executable,
586
+ const inspect = (args, id) => runHarnessAdapter({ executable: manifest.adapters[runtimeHarness(session.profile)].executable,
587
587
  args, env: controllerEnvironment(context, manifest, session), runtimeOwner: session.owner, operationId: id, timeoutMs: 30_000, signal: cancellation.signal });
588
588
  const resumed = await continueCodexCapacity({ error, sessionId: session.id, operationId: firstOperationId, signal: cancellation.signal, allowSettledDescendants: true, atomicIntent: true,
589
589
  retainedIntents: () => context.db.all("SELECT receipt_json FROM run_controller_operations WHERE controller_id = ? AND generation = ?", [row.controller_id, row.generation])
@@ -699,6 +699,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
699
699
  if (manifest.request.capture_root)
700
700
  await captureControllerBoundary(executionContext, row, manifest, completed, null, assertPhysicalOwner);
701
701
  setStatus("stop_target_reached");
702
+ setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
702
703
  appendEvent(context, row.controller_id, "stop_target_reached", { stage: state.stage, attempt: completed.attempt ?? null });
703
704
  return;
704
705
  }
@@ -0,0 +1,149 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, managedProcessObservation, processTreeIsAlive } from "./managed-processes.js";
3
+ import { reconcileStoredFlowRun } from "./runs.js";
4
+ import { recoveryGuard } from "./run-recovery.js";
5
+ import { spawnSync } from "node:child_process";
6
+ function physicalInventory() {
7
+ const reply = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { encoding: 'utf8', timeout: 5000 });
8
+ if (reply.status !== 0)
9
+ return null;
10
+ return reply.stdout.trim().split('\n').map(line => line.trim().split(/\s+/)).filter(parts => parts.length === 3).map(parts => ({ pid: Number(parts[0]), group: Number(parts[1]), zombie: parts[2].startsWith('Z') }));
11
+ }
12
+ const terminalRun = (status) => ["done", "failed", "cancelled", "discarded"].includes(status);
13
+ const waiting = new Set(["waiting_for_user", "waiting_for_context", "waiting_for_capacity", "waiting_for_children", "waiting_for_merge", "stop_target_reached", "paused", "control_requested"]);
14
+ const table = (context, name) => Boolean(context.db.get("SELECT 1 FROM sqlite_schema WHERE name = ?", [name]));
15
+ const latest = (values) => values.filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value))).sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? null;
16
+ /** Evidence only: lease expiry and quiet providers never prove an exit. */
17
+ export function assessRunHealth(input) {
18
+ const phase = input.phase ?? null;
19
+ let state = "unknown", reason = "owner_identity_missing";
20
+ if (terminalRun(input.status)) {
21
+ state = "settled";
22
+ reason = "saved_terminal_result";
23
+ }
24
+ else if (input.childrenAlive && input.owner === "absent") {
25
+ state = "owner_lost";
26
+ reason = "native_children_still_active";
27
+ }
28
+ else if (phase === 'recovery_required' || phase === 'capture_failed') {
29
+ state = input.owner === 'absent' ? 'owner_lost' : 'unknown';
30
+ reason = phase;
31
+ }
32
+ else if (phase && waiting.has(phase) || ["waiting_for_user", "blocked"].includes(input.status)) {
33
+ state = "waiting";
34
+ reason = phase ?? input.status;
35
+ }
36
+ else if (input.owner === "absent") {
37
+ state = "owner_lost";
38
+ reason = "physical_owner_retired";
39
+ }
40
+ else if (input.owner === "alive") {
41
+ const age = input.lastProgress ? Date.parse(input.now) - Date.parse(input.lastProgress) : NaN;
42
+ state = Number.isFinite(age) && age > input.noProgressMs ? "no_progress" : "active";
43
+ reason = state === "no_progress" ? "progress_overdue" : "physical_owner_observed";
44
+ }
45
+ else if (input.status === "paused") {
46
+ state = "waiting";
47
+ reason = "saved_pause";
48
+ }
49
+ return { schema_id: "dd-flow/run-health@1", state, reason, phase, owner_state: input.owner, last_progress_at: input.lastProgress ?? null, last_heartbeat_at: input.heartbeat ?? null, observed_at: input.now, controller_id: input.controllerId ?? null, generation: input.generation ?? null, no_progress_ms: input.noProgressMs };
50
+ }
51
+ /** No dispatch, cancellation, process signals or provider calls. All changes
52
+ * are fenced by the retained RUN revision, controller and process binding. */
53
+ export function reconcileFlowRunHealth(context, input = {}) {
54
+ const noProgressMs = input.noProgressMs ?? 30 * 60_000;
55
+ if (!Number.isSafeInteger(noProgressMs) || noProgressMs < 1_000)
56
+ throw new AppError("usage", "no-progress-ms must be an integer of at least 1000", 2);
57
+ const runs = context.db.all(`SELECT * FROM runs${input.runId ? " WHERE (id = ? OR short_id = ?)" : ""}`, input.runId ? [input.runId, input.runId] : []).filter(run => !input.projectRoot || run.project_root === input.projectRoot);
58
+ if (input.runId && runs.length !== 1)
59
+ throw new AppError("not_found", "RUN scope is missing or ambiguous", 1);
60
+ const processes = physicalInventory();
61
+ const alive = (record) => {
62
+ if (!record.pid)
63
+ return !['stopped', 'failed'].includes(record.state);
64
+ const physical = processes?.find(item => item.pid === record.pid);
65
+ if (processes && (!physical || physical.zombie)) {
66
+ const metadata = JSON.parse(record.metadata_json);
67
+ // A retired group leader may still own physical children. Merely ending
68
+ // the controller does not settle those children or remote provider work.
69
+ return Boolean(metadata.process_group_id && processes.some(item => item.group === metadata.process_group_id && !item.zombie));
70
+ }
71
+ return !record.pid_started_at || processTreeIsAlive(record);
72
+ };
73
+ return { ok: true, results: runs.map(run => {
74
+ const index = JSON.parse(run.index_json);
75
+ const controllers = table(context, "run_controllers") ? context.db.all("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? ORDER BY generation DESC, created_at DESC", [run.project_id, run.id]) : [];
76
+ const controller = controllers[0];
77
+ const manifest = controller ? JSON.parse(controller.manifest_json) : null;
78
+ const resourceContext = manifest?.resource_home ? { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: manifest.resource_home } } : context;
79
+ let records = [], inventoryComplete = false;
80
+ try {
81
+ records = managedProcessStatus(resourceContext).filter(record => record.project_id === run.project_id && record.run_id === run.id && managedProcessRuntimeHome(record, context) === context.ddFlowHome);
82
+ inventoryComplete = true;
83
+ }
84
+ catch { /* Missing registry is uncertainty, never retirement proof. */ }
85
+ const physical = controller && records.find(record => record.id === controller.process_id && record.lease_token === controller.process_lease_token && record.owner_id === controller.controller_id && record.kind === "run-controller");
86
+ const owner = physical?.pid && physical.pid_started_at ? processes && !processes.some(item => item.pid === physical.pid && !item.zombie) && !alive(physical) ? 'absent' : managedProcessObservation(physical) : "unknown";
87
+ const retireController = Boolean(controller && owner === 'absent' && !['completed', 'cancelled', 'superseded', 'stop_target_reached', 'recovery_required', 'capture_failed', 'waiting_for_user', 'waiting_for_context', 'control_requested', 'paused'].includes(controller.status));
88
+ const phase = retireController ? 'recovery_required' : controller?.status ?? null;
89
+ const childrenAlive = records.some(record => record.id !== physical?.id && alive(record)) || Boolean(physical && owner === 'absent' && alive(physical));
90
+ const progressEvent = controller && table(context, "run_controller_events") ? context.db.get("SELECT MAX(created_at) AS at FROM run_controller_events WHERE controller_id = ? AND event_type NOT IN ('health_reconciled','heartbeat')", [controller.controller_id])?.at : null;
91
+ const workProgress = table(context, "works") ? context.db.get("SELECT MAX(at) AS at FROM (SELECT created_at AS at FROM works WHERE project_id=? AND run_id=? UNION ALL SELECT started_at FROM works WHERE project_id=? AND run_id=? UNION ALL SELECT completed_at FROM works WHERE project_id=? AND run_id=?)", [run.project_id, run.id, run.project_id, run.id, run.project_id, run.id])?.at : null;
92
+ let hookProgress = null;
93
+ if (table(context, 'hook_events') && table(context, 'sessions'))
94
+ try {
95
+ hookProgress = context.db.get("SELECT MAX(h.created_at) AS at FROM hook_events h JOIN sessions s ON s.session_id=h.session_id AND s.project_id=h.project_id WHERE s.project_id=? AND s.run_id=? AND h.event_name IN ('PostToolUse','UserPromptSubmit','SubagentStop','Stop','SessionStart')", [run.project_id, run.id])?.at ?? null;
96
+ }
97
+ catch { /* Legacy hooks have no certified Run membership. */ }
98
+ const lastProgress = latest([run.created_at, progressEvent, workProgress, hookProgress, ...index.stage_runs.flatMap(stage => [stage.started_at, stage.completed_at])]);
99
+ const policy = input.noProgressMs ?? (phase === 'starting' ? 120_000 : phase === 'capturing' ? 300_000 : 30 * 60_000);
100
+ const health = assessRunHealth({ status: run.status, phase, owner, childrenAlive, lastProgress, heartbeat: physical?.updated_at ?? null, now: context.now(), noProgressMs: policy, controllerId: controller?.controller_id ?? null, generation: controller?.generation ?? null });
101
+ if (terminalRun(run.status) && (childrenAlive || owner === "alive")) {
102
+ health.state = "unknown";
103
+ health.reason = "terminal_result_has_live_runtime";
104
+ }
105
+ const sessionState = controller ? JSON.parse(controller.state_json) : null;
106
+ const nativeSettled = Boolean(sessionState?.sessions.length && sessionState.sessions.every(session => session.stopped === true));
107
+ const outcomeUnknown = controller && table(context, "run_controller_operations") && context.db.get("SELECT 1 FROM run_controller_operations WHERE controller_id = ? AND generation = ? AND status IN ('accepted','outcome_unknown') LIMIT 1", [controller.controller_id, controller.generation]);
108
+ let transition;
109
+ const guard = table(context, 'run_recovery_guards') ? recoveryGuard(context, run.project_id, run.id) : null;
110
+ const currentGeneration = !guard || !controller || controller.generation === guard.generation;
111
+ if (!terminalRun(run.status) && currentGeneration) {
112
+ if (controller && ["recovery_required", "capture_failed"].includes(controller.status))
113
+ transition = "paused";
114
+ else if (controller?.status === "stop_target_reached" && owner !== "alive" && nativeSettled && inventoryComplete && !childrenAlive)
115
+ transition = "paused";
116
+ else if (health.state === "owner_lost" && owner === "absent") {
117
+ transition = controller?.last_error_json && nativeSettled && inventoryComplete && !childrenAlive && !outcomeUnknown && !guard ? "failed" : "paused";
118
+ }
119
+ }
120
+ const nextStatus = transition ?? run.status;
121
+ if (transition === 'failed') {
122
+ health.state = 'settled';
123
+ health.reason = 'saved_terminal_result';
124
+ }
125
+ const unchanged = index.health && JSON.stringify({ ...index.health, observed_at: null }) === JSON.stringify({ ...health, observed_at: null }) && nextStatus === run.status;
126
+ let changed = false;
127
+ if (input.apply && !unchanged) {
128
+ // Verify the same physical inventory immediately before publishing. A
129
+ // replacement/renewal invalidates this observation, even with the same PID.
130
+ const binding = (items) => JSON.stringify(items.map(item => ({ ...managedProcessBinding(item, manifest?.resource_home ?? context.ddFlowHome), state: item.state, updated_at: item.updated_at, lease_expires_at: item.lease_expires_at })));
131
+ if (inventoryComplete) {
132
+ const fresh = managedProcessStatus(resourceContext).filter(record => record.project_id === run.project_id && record.run_id === run.id && managedProcessRuntimeHome(record, context) === context.ddFlowHome);
133
+ if (binding(fresh) !== binding(records) || physical && owner === "absent" && managedProcessObservation(physical) !== 'absent')
134
+ throw new AppError("run_health_changed", "Runtime ownership changed during reconciliation", 1);
135
+ }
136
+ context.db.writeTransaction(() => {
137
+ const current = controller && context.db.get("SELECT * FROM run_controllers WHERE controller_id = ?", [controller.controller_id]);
138
+ if (controller && JSON.stringify(current) !== JSON.stringify(controller))
139
+ throw new AppError("run_health_changed", "Controller changed during reconciliation", 1);
140
+ reconcileStoredFlowRun(context, run, health, transition, inventoryComplete && owner !== "alive" && !childrenAlive && nativeSettled);
141
+ if (controller && retireController) {
142
+ context.db.run("UPDATE run_controllers SET status = 'recovery_required', updated_at = ? WHERE controller_id = ? AND owner_token = ? AND generation = ?", [context.now(), controller.controller_id, controller.owner_token, controller.generation]);
143
+ }
144
+ changed = true;
145
+ });
146
+ }
147
+ return { project_id: run.project_id, run_id: run.id, previous_status: run.status, status: nextStatus, health, changed, would_change: !unchanged };
148
+ }) };
149
+ }