@deksden-com/dd-flow-cli 0.9.0-beta.135 → 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.
- package/CHANGELOG.md +6 -0
- package/README.md +2 -0
- package/dist/build-info.json +7 -7
- package/dist/cli/command-inputs.js +1 -0
- package/dist/cli/command-metadata.js +144 -0
- package/dist/cli/command-option-descriptions.js +186 -0
- package/dist/cli/completion-cli.js +70 -0
- package/dist/cli/completion.js +57 -0
- package/dist/cli/error-output.js +52 -0
- package/dist/cli/executor-onboarding.js +71 -0
- package/dist/cli/harnesses.js +84 -0
- package/dist/cli/models.js +39 -0
- package/dist/cli/operator-profiles.js +61 -0
- package/dist/cli/output-options.js +35 -0
- package/dist/cli/response-file.js +69 -0
- package/dist/cli/run-cli.js +5 -0
- package/dist/cli/setup.js +147 -0
- package/dist/services/cli-operation-classifier.js +4 -0
- package/dist/services/cli-upgrade-allowlist.js +27 -0
- package/dist/services/discovery/agy-discovery.js +2 -0
- package/dist/services/discovery/codex-discovery.js +2 -0
- package/dist/services/discovery/droid-discovery.js +2 -0
- package/dist/services/discovery/grok-discovery.js +2 -0
- package/dist/services/discovery/opencode-discovery.js +2 -0
- package/dist/services/discovery/static.js +31 -0
- package/dist/services/discovery/zcode-discovery.js +2 -0
- package/dist/services/engines.js +1 -1
- package/dist/services/executor-onboarding.js +56 -0
- package/dist/services/harness-discovery.js +29 -0
- package/dist/services/harness-doctor.js +75 -0
- package/dist/services/harness-probe-effects.js +51 -0
- package/dist/services/harness-version-probe.js +100 -0
- package/dist/services/hybrid-contracts.js +455 -0
- package/dist/services/installation-lock.js +92 -0
- package/dist/services/managed-processes.js +30 -0
- package/dist/services/model-catalog.js +109 -0
- package/dist/services/model-discovery.js +46 -0
- package/dist/services/operator-profiles.js +236 -0
- package/dist/services/release-archive-producer.js +52 -0
- package/dist/services/release-archive.js +143 -0
- package/dist/services/release-cache.js +142 -0
- package/dist/services/release-compatibility.js +18 -0
- package/dist/services/release-download.js +67 -0
- package/dist/services/release-envelope.js +104 -0
- package/dist/services/release-manifest.js +45 -0
- package/dist/services/release-payload.js +114 -0
- package/dist/services/release-producer.js +63 -0
- package/dist/services/release-provenance.js +160 -0
- package/dist/services/release-registry.js +66 -0
- package/dist/services/release-source.js +194 -0
- package/dist/services/run-controller.js +1 -0
- package/dist/services/run-health.js +149 -0
- package/dist/services/runs.js +61 -13
- package/dist/services/setup.js +377 -0
- package/dist/storage/database.js +18 -3
- package/dist/storage/writer-contract.js +8 -0
- package/package.json +1 -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
|
+
}
|
|
@@ -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
|
+
}
|