@namewta/speculo 1.0.17 → 1.0.19
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/package.json +5 -2
- package/template/canonical/canonical-specdev-goal-plan.md +2 -2
- package/template/canonical/canonical-specdev-grill-with-docs.md +2 -2
- package/template/canonical/canonical-specdev-spec.md +11 -136
- package/template/canonical/canonical-specdev-tickets.md +2 -2
- package/template/canonical/canonical-specdev-wayfinder.md +2 -2
- package/template/commands/handoff.md +16 -0
- package/template/commands/retro.md +1 -1
- package/template/skills/github-npm-ops/references/issue-pr-triage.md +8 -0
- package/template/skills/speculo-retro/references/entry-procedure.md +19 -3
- package/template/skills/speculo-retro/references/issue-drafting-sop.md +3 -1
- package/template/workflows/learning/A-archive/A-archive.md +2 -5
- package/template/workflows/learning/A-assess-and-plan/A-assess-and-plan.md +2 -5
- package/template/workflows/learning/C-consolidate/C-consolidate.md +2 -5
- package/template/workflows/learning/G-goal/G-goal.md +5 -23
- package/template/workflows/learning/H-homework/H-homework.md +2 -5
- package/template/workflows/learning/I-init-setup/I-init-setup.md +4 -9
- package/template/workflows/learning/L-lesson/L-lesson.md +2 -5
- package/template/workflows/learning/Q-question/Q-question.md +16 -8
- package/template/workflows/learning/Q-question/inquiry-template.md +2 -0
- package/template/workflows/learning/Q-question/tools/validate-inquiry.mjs +112 -0
- package/template/workflows/learning/R-review/R-review.md +3 -6
- package/template/workflows/learning/README.md +2 -2
- package/template/workflows/learning/common/rules/knowledge-organization.md +1 -1
- package/template/workflows/learning/common/rules/mastery-policy.md +1 -1
- package/template/workflows/learning/common/skills/socratic-questioning/SKILL.md +1 -1
- package/template/workflows/ops/D-project-deploy/D-project-deploy.md +15 -9
- package/template/workflows/ops/H-host-manage/H-host-manage.md +14 -21
- package/template/workflows/ops/H-host-manage/references/host-profiles.md +21 -0
- package/template/workflows/ops/I-initialize/I-initialize.md +7 -7
- package/template/workflows/ops/INDEX.md +20 -16
- package/template/workflows/ops/README.md +29 -73
- package/template/workflows/ops/S-server-connect/S-server-connect.md +28 -0
- package/template/workflows/ops/V-inventory-view/V-inventory-view.md +30 -0
- package/template/workflows/ops/common/CAPABILITIES.md +22 -15
- package/template/workflows/ops/common/WORKSPACE-USAGE.md +78 -0
- package/template/workflows/ops/common/examples/task-host.example.json +64 -0
- package/template/workflows/ops/common/rules/activation-and-memory.md +5 -3
- package/template/workflows/ops/common/rules/deployment-layout.md +51 -0
- package/template/workflows/ops/common/rules/inventory-and-records.md +55 -0
- package/template/workflows/ops/common/rules/recovery.md +6 -0
- package/template/workflows/ops/common/rules/research-and-decisions.md +18 -0
- package/template/workflows/ops/common/rules/server-onboarding.md +27 -0
- package/template/workflows/ops/common/rules/workspace-and-authorization.md +39 -0
- package/template/workflows/ops/common/schemas/fleet-view.schema.json +487 -0
- package/template/workflows/ops/common/schemas/server-check.schema.json +95 -0
- package/template/workflows/ops/common/schemas/task-request.schema.json +138 -0
- package/template/workflows/ops/common/templates/FLEET.html +61 -0
- package/template/workflows/ops/common/tests/test_ops_workspace.mjs +246 -0
- package/template/workflows/ops/common/tools/ops.mjs +5 -3
- package/template/workflows/ops/common/tools/opslib/fleet.mjs +155 -0
- package/template/workflows/ops/common/tools/opslib/server_checks.mjs +149 -0
- package/template/workflows/ops/common/tools/opslib/tasks.mjs +313 -0
- package/template/workflows/ops/common/tools/opslib/workspace.mjs +107 -0
- package/template/workflows/ops/common/tools/opslib/workspace_cli.mjs +80 -0
- package/template/workflows/ops/common/tools/validate-ops.mjs +11 -8
- package/template/workflows/ops/manifest.json +43 -7
- package/template/workflows/person/M-mao-zedong-cognitive-os/M-mao-zedong-cognitive-os.md +13 -68
- package/template/workflows/person/S-steelman-deliberation/S-steelman-deliberation.md +11 -123
- package/template/workflows/person/S-steelman-deliberation/references/state-and-publication.md +86 -0
- package/template/workflows/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md +2 -17
- package/template/workflows/specdev/A-archive-and-consolidate/consolidation-interview.md +10 -1
- package/template/workflows/specdev/C-code-review/C-code-review.md +3 -14
- package/template/workflows/specdev/C-code-review/code-review-template.md +4 -0
- package/template/workflows/specdev/D-diagnose-bugs/D-diagnose-bugs.md +1 -23
- package/template/workflows/specdev/G-grill-with-docs/G-grill-with-docs.md +2 -2
- package/template/workflows/specdev/I-implement/I-implement.md +2 -2
- package/template/workflows/specdev/I-implement/evidence-template.md +8 -0
- package/template/workflows/specdev/I-implement/references/implementation-procedure.md +8 -42
- package/template/workflows/specdev/I-init-setup/I-init-setup.md +16 -117
- package/template/workflows/specdev/I-init-setup/references/first-setup.md +46 -0
- package/template/workflows/specdev/L-learn-change/L-learn-change.md +11 -89
- package/template/workflows/specdev/L-learn-change/references/lesson-format.md +47 -0
- package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +2 -2
- package/template/workflows/specdev/P-prototype/P-prototype.md +2 -16
- package/template/workflows/specdev/R-review-architecture/R-review-architecture.md +19 -57
- package/template/workflows/specdev/R-review-architecture/architecture-review-template.md +2 -1
- package/template/workflows/specdev/R-review-architecture/references/selected-design.md +23 -0
- package/template/workflows/specdev/R-review-architecture/review-rubric.md +2 -2
- package/template/workflows/specdev/README.md +4 -4
- package/template/workflows/specdev/S-spec/S-spec.md +11 -138
- package/template/workflows/specdev/T-tickets/T-tickets.md +2 -2
- package/template/workflows/specdev/T-triage/T-triage.md +21 -136
- package/template/workflows/specdev/T-triage/intake-protocol.md +19 -0
- package/template/workflows/specdev/W-wayfinder/W-wayfinder.md +2 -2
- package/template/workflows/specdev/common/rules/deployment-handoff.md +23 -0
- package/template/workflows/specdev/common/skills/code-review/SKILL.md +5 -4
- package/template/workflows/specdev/common/skills/code-review/references/result-reuse.md +15 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Allowlisted offline inventory. Never reads credentials, env, outboxes or raw task specifications. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { atomicWrite, digest, identifier, noSymlinks, OpsError, redact, withLock, writeJson } from "./core.mjs";
|
|
7
|
+
import { catalogUnlocked, ids, readBoundedJson, readRegistry, statePath, targetDigest } from "./workspace.mjs";
|
|
8
|
+
import { latestCheck, readiness } from "./server_checks.mjs";
|
|
9
|
+
|
|
10
|
+
const TEMPLATE = join(dirname(fileURLToPath(import.meta.url)), "../../templates/FLEET.html");
|
|
11
|
+
const text = (v) => typeof v === "string" ? redact(v.replace(/[\u0000-\u001f\u007f]/g, " "), []).slice(0, 500) : "";
|
|
12
|
+
const time = (v) => typeof v === "string" && Number.isFinite(Date.parse(v)) ? new Date(v).toISOString() : null;
|
|
13
|
+
const sorted = (map) => Object.keys(map).sort().map((id) => map[id]);
|
|
14
|
+
const knownStatus = new Set(["planned", "running", "configured", "docs_pending", "completed", "failed", "unknown", "retired", "blocked", "partial", "prepared", "authorized", "interrupted", "incomplete", "needs-inspection", "executing", "planned-not-authorized"]);
|
|
15
|
+
const status = (v) => knownStatus.has(v) ? v : "unknown";
|
|
16
|
+
const md = (v) => text(String(v ?? "—")).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("|", "|").replaceAll("`", "`").replaceAll("[", "[").replaceAll("]", "]");
|
|
17
|
+
|
|
18
|
+
function taskSummaries(state) {
|
|
19
|
+
const root = join(state, "records", "tasks");
|
|
20
|
+
noSymlinks(root);
|
|
21
|
+
if (!existsSync(root)) return [];
|
|
22
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
23
|
+
if (entries.length > 10000) throw new OpsError("task index exceeds 10000 entries");
|
|
24
|
+
return entries.sort((a, b) => a.name.localeCompare(b.name)).map((ent) => {
|
|
25
|
+
identifier(ent.name, "task_id");
|
|
26
|
+
if (!ent.isDirectory() || ent.isSymbolicLink()) throw new OpsError("invalid task index entry");
|
|
27
|
+
const folder = join(root, ent.name), summary = join(folder, "summary.json");
|
|
28
|
+
// Incomplete preparation stays visible without reading possibly secret-bearing task.json.
|
|
29
|
+
if (!existsSync(summary)) return { task_id: ent.name, scope: "unknown", server_ids: [], project_ids: [], status: "incomplete", updated_at: null, run_ids: [] };
|
|
30
|
+
const s = readBoundedJson(summary);
|
|
31
|
+
if (s.schema_version !== 1 || s.artifact !== "ops-task-summary" || s.task_id !== ent.name || !["server", "project"].includes(s.scope)) throw new OpsError("invalid task summary");
|
|
32
|
+
ids(s.server_ids, "server_ids"); ids(s.project_ids, "project_ids");
|
|
33
|
+
const result = ["result.json", "progress.json"].map((f) => join(folder, f)).find((p) => { noSymlinks(p); return existsSync(p); });
|
|
34
|
+
const r = result ? readBoundedJson(result) : null;
|
|
35
|
+
if (r && (r.artifact !== "ops-task-result" || r.task_id !== ent.name || !Array.isArray(r.steps))) throw new OpsError("invalid task result");
|
|
36
|
+
for (const f of ["started.json", "authorization.json", "task.json"]) noSymlinks(join(folder, f));
|
|
37
|
+
let st = r ? status(r.status) : existsSync(join(folder, "started.json")) ? "needs-inspection"
|
|
38
|
+
: existsSync(join(folder, "authorization.json")) ? "authorized" : existsSync(join(folder, "task.json")) ? "prepared" : "incomplete";
|
|
39
|
+
// A running record is not proof that a live process still owns the task.
|
|
40
|
+
if (st === "running") st = "needs-inspection";
|
|
41
|
+
const runIds = (r?.steps || []).map((step) => identifier(step.run_id, "run_id"));
|
|
42
|
+
return { task_id: s.task_id, scope: s.scope, server_ids: s.server_ids, project_ids: s.project_ids,
|
|
43
|
+
status: st, updated_at: time(r?.updated_at ?? s.created_at), run_ids: [...new Set(runIds)] };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function projectFleet(registry, checks = {}, tasks = [], { at = new Date(), staleHours = 24 } = {}) {
|
|
48
|
+
if (!Number.isFinite(at.getTime()) || !Number.isFinite(staleHours) || staleHours <= 0 || staleHours > 8760) throw new OpsError("invalid inventory time/staleness policy");
|
|
49
|
+
const deployments = sorted(registry.deployments).map((d) => ({
|
|
50
|
+
deployment_id: d.deployment_id, project_id: d.project_id, server_id: d.host_id,
|
|
51
|
+
environment: text(d.environment), instance: text(d.instance), method: text(d.method), root: text(d.root),
|
|
52
|
+
status: status(d.status), desired_version: text(d.version), observed_version: d.observed_version == null ? null : text(d.observed_version),
|
|
53
|
+
updated_at: time(d.updated_at), run_id: d.run_id ? identifier(d.run_id, "run_id") : null,
|
|
54
|
+
}));
|
|
55
|
+
const byServer = new Map(), byProject = new Map();
|
|
56
|
+
for (const d of deployments) {
|
|
57
|
+
if (!byServer.has(d.server_id)) byServer.set(d.server_id, []);
|
|
58
|
+
if (!byProject.has(d.project_id)) byProject.set(d.project_id, []);
|
|
59
|
+
byServer.get(d.server_id).push(d); byProject.get(d.project_id).push(d);
|
|
60
|
+
}
|
|
61
|
+
const servers = sorted(registry.hosts).map((h) => {
|
|
62
|
+
const c = checks[h.host_id];
|
|
63
|
+
let checkStatus = "unknown";
|
|
64
|
+
if (c) {
|
|
65
|
+
try { if (targetDigest(h) === c.host_digest) checkStatus = readiness(c.checks, c.checked_at, { at, staleHours }); }
|
|
66
|
+
catch { /* A changed/missing pinned host key is not evidence of readiness. */ }
|
|
67
|
+
}
|
|
68
|
+
const own = byServer.get(h.host_id) || [];
|
|
69
|
+
return { server_id: h.host_id, name: text(h.display_name), platform: text(h.platform), transport: h.transport,
|
|
70
|
+
address: h.transport === "ssh" ? text(h.connection.hostname) : "local (explicit)", port: h.transport === "ssh" ? h.connection.port ?? 22 : null,
|
|
71
|
+
root: text(h.root), readiness: checkStatus, profile: c?.profile ?? null, checked_at: time(c?.checked_at),
|
|
72
|
+
checks: (c?.checks || []).map((v) => ({ id: v.id, status: v.status, required: v.required })),
|
|
73
|
+
project_ids: [...new Set(own.filter((d) => d.status !== "retired").map((d) => d.project_id))].sort(),
|
|
74
|
+
deployment_ids: own.map((d) => d.deployment_id),
|
|
75
|
+
host_services: (h.host_services || []).map((s) => ({ service_id: identifier(s.id, "host service"), kind: text(s.kind), unit: text(s.unit), port: Number.isInteger(s.listen_port) ? s.listen_port : null })),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
const projects = sorted(registry.projects).map((p) => {
|
|
79
|
+
const own = byProject.get(p.project_id) || [];
|
|
80
|
+
return { project_id: p.project_id, name: text(p.display_name ?? p.project_id), kind: text(p.kind), service_type: text(p.service_type),
|
|
81
|
+
server_ids: [...new Set(own.filter((d) => d.status !== "retired").map((d) => d.server_id))].sort(), deployment_ids: own.map((d) => d.deployment_id) };
|
|
82
|
+
});
|
|
83
|
+
const bindings = sorted(registry.bindings).map((b) => {
|
|
84
|
+
if (!registry.deployments[b.consumer_deployment_id] || (b.provider_deployment_id && !registry.deployments[b.provider_deployment_id])) throw new OpsError("orphan binding cannot be projected");
|
|
85
|
+
return { binding_id: b.binding_id, consumer_deployment_id: b.consumer_deployment_id,
|
|
86
|
+
provider_deployment_id: b.provider_deployment_id ?? null, mode: text(b.mode), status: text(b.status) };
|
|
87
|
+
});
|
|
88
|
+
const releases = sorted(registry.releases).map((r) => ({ run_id: identifier(r.run_id, "run_id"), worker: text(r.worker), status: status(r.status),
|
|
89
|
+
server_ids: ids(r.host_ids || [], "release hosts"), deployment_ids: ids(r.deployment_ids || [], "release deployments"), updated_at: time(r.updated_at) }));
|
|
90
|
+
return { schema_version: 1, artifact: "ops-fleet-view", generated_at: at.toISOString(), controller_id: registry.controller.controller_id,
|
|
91
|
+
source_revision: registry.revision, source_digest: digest(registry), stale_hours: staleHours,
|
|
92
|
+
notice: "ready 只表示指定 profile 的必需检查项通过;completed 是历史执行结果。未测、过期或身份变化不能视为生产验收通过。",
|
|
93
|
+
servers, projects, deployments, bindings, releases, tasks };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function renderMarkdown(data, html = "index.html", json = "inventory.json") {
|
|
97
|
+
const table = (headers, rows) => `| ${headers.join(" | ")} |\n| ${headers.map(() => "---").join(" | ")} |\n` + (rows.length ? rows.map((r) => `| ${r.map(md).join(" | ")} |`).join("\n") : `| ${headers.map((_, i) => i ? "—" : "暂无记录").join(" | ")} |`) + "\n";
|
|
98
|
+
return `# OPS 服务器与部署清单\n\n[打开离线看板](${html}) · [结构化快照](${json})\n\n生成:${data.generated_at};资源修订:${data.source_revision};控制端:${data.controller_id}。\n\n这是离线记录,不是实时监控。ready 仅代表指定检查 profile 的必需项通过;completed 是历史执行结果。未测、过期、身份变化不视为正常。\n\n## 服务器 → 项目\n\n` +
|
|
99
|
+
table(["服务器", "名称 / 地址", "系统", "检查 / profile", "检查时间", "项目", "主机级服务"], data.servers.map((s) => [s.server_id, `${s.name} / ${s.address}`, s.platform, `${s.readiness} / ${s.profile ?? "未检查"}`, s.checked_at, s.project_ids.join(", "), s.host_services.map((v) => v.service_id).join(", ")])) +
|
|
100
|
+
`\n## 项目 → 服务器\n\n` + table(["项目", "名称", "类别", "服务器", "部署实例"], data.projects.map((p) => [p.project_id, p.name, p.kind, p.server_ids.join(", "), p.deployment_ids.join(", ")])) +
|
|
101
|
+
`\n## 部署实例\n\n` + table(["部署", "项目", "服务器", "环境", "记录状态", "计划版本", "验证版本", "根目录"], data.deployments.map((d) => [d.deployment_id, d.project_id, d.server_id, d.environment, d.status, d.desired_version, d.observed_version, d.root])) +
|
|
102
|
+
`\n## 任务记录\n\n` + table(["任务", "范围", "状态", "服务器", "Run"], data.tasks.map((t) => [t.task_id, t.scope, t.status, t.server_ids.join(", "), t.run_ids.join(", ")])) +
|
|
103
|
+
`\n## 原始 Run / Release 索引\n\n` + table(["Run", "Work", "记录状态", "服务器", "更新时间"], data.releases.map((r) => [r.run_id, r.worker, r.status, r.server_ids.join(", "), r.updated_at])) +
|
|
104
|
+
`\n原始不可覆盖证据仍位于 hosts/<host_id>/runs/<run_id>/ 或 releases/<run_id>/;通过 ops.mjs inspect-run 核对。此视图不移动、不替代执行回执,不导出 env、凭据账本或配置镜像。\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function renderHtml(data, template = readFileSync(TEMPLATE, "utf8")) {
|
|
108
|
+
template = template.replace(/\r\n?/g, "\n");
|
|
109
|
+
for (const marker of ["{{FLEET_DATA}}", "{{SCRIPT_CSP}}", "{{STYLE_CSP}}"]) if (template.split(marker).length !== 2) throw new OpsError(`HTML template must contain one ${marker}`);
|
|
110
|
+
const payload = JSON.stringify(data).replace(/[<>&\u2028\u2029]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
111
|
+
let html = template.replace("{{FLEET_DATA}}", () => payload);
|
|
112
|
+
const scripts = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)].filter((m) => !m[0].startsWith('<script type="application/json"'));
|
|
113
|
+
const styles = [...html.matchAll(/<style>([\s\S]*?)<\/style>/g)];
|
|
114
|
+
if (scripts.length !== 1 || styles.length !== 1) throw new OpsError("offline template requires one audited inline script and style");
|
|
115
|
+
const csp = (s) => "sha256-" + createHash("sha256").update(s).digest("base64");
|
|
116
|
+
html = html.replace("{{SCRIPT_CSP}}", csp(scripts[0][1])).replace("{{STYLE_CSP}}", csp(styles[0][1]));
|
|
117
|
+
return html;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function generateFleet(state, { at = new Date(), staleHours = 24 } = {}) {
|
|
121
|
+
statePath(state);
|
|
122
|
+
return withLock(join(state, "records", ".locks", "views"), { operation: "fleet" }, () => {
|
|
123
|
+
catalogUnlocked(state);
|
|
124
|
+
const registry = readRegistry(state);
|
|
125
|
+
const checks = Object.fromEntries(sorted(registry.hosts).map((h) => [h.host_id, latestCheck(state, h)]));
|
|
126
|
+
const data = projectFleet(registry, checks, taskSummaries(state), { at, staleHours });
|
|
127
|
+
const html = renderHtml(data), markdown = renderMarkdown(data);
|
|
128
|
+
// Snapshot identity includes renderer bytes, so a template update cannot collide with an older view.
|
|
129
|
+
const viewId = "view-" + digest({ data, html, markdown }).slice(0, 32);
|
|
130
|
+
const folder = join(state, "records", "views", viewId);
|
|
131
|
+
const files = { "inventory.json": JSON.stringify(data, null, 2) + "\n", "index.html": html, "FLEET.md": markdown };
|
|
132
|
+
const receipt = { schema_version: 1, artifact: "ops-fleet-receipt", view_id: viewId, source_digest: data.source_digest,
|
|
133
|
+
files: Object.fromEntries(Object.entries(files).map(([name, body]) => [name, digest(Buffer.from(body))])) };
|
|
134
|
+
noSymlinks(folder);
|
|
135
|
+
if (existsSync(folder)) {
|
|
136
|
+
const old = readBoundedJson(join(folder, "manifest.json"));
|
|
137
|
+
if (digest(old) !== digest(receipt)) throw new OpsError("existing view receipt differs; preserve evidence");
|
|
138
|
+
} else {
|
|
139
|
+
for (const [name, body] of Object.entries(files)) atomicWrite(join(folder, name), body, 0o600, { exclusive: true });
|
|
140
|
+
writeJson(join(folder, "manifest.json"), receipt, { exclusive: true });
|
|
141
|
+
}
|
|
142
|
+
for (const [name, expected] of Object.entries(receipt.files)) {
|
|
143
|
+
const path = join(folder, name); noSymlinks(path, { allowMissing: false });
|
|
144
|
+
if (digest(readFileSync(path)) !== expected) throw new OpsError("view readback mismatch; previous landing page preserved");
|
|
145
|
+
}
|
|
146
|
+
catalogUnlocked(state);
|
|
147
|
+
if (digest(readRegistry(state)) !== data.source_digest) throw new OpsError("catalog drift during rendering; previous landing page preserved");
|
|
148
|
+
const prefix = `records/views/${viewId}/`;
|
|
149
|
+
// Publish one landing page last; JSON + HTML always belong to the same immutable generation.
|
|
150
|
+
atomicWrite(join(state, "FLEET.md"), renderMarkdown(data, prefix + "index.html", prefix + "inventory.json"));
|
|
151
|
+
return { status: "generated", view_id: viewId, markdown: join(state, "FLEET.md"), html: join(folder, "index.html"),
|
|
152
|
+
json: join(folder, "inventory.json"), source_revision: data.source_revision, servers: data.servers.length, projects: data.projects.length,
|
|
153
|
+
note: "Local restricted snapshot only; no server connection, no live health claim, no credential export" };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Bounded, read-only SSH/public-key and runtime checks; never installs or changes sshd. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join, posix } from "node:path";
|
|
5
|
+
import { digest, identifier, newId, now, noSymlinks, OpsError, withLock, writeJson } from "./core.mjs";
|
|
6
|
+
import { readBoundedJson, readRegistry, statePath, targetDigest } from "./workspace.mjs";
|
|
7
|
+
|
|
8
|
+
export const CHECK_IDS = Object.freeze([
|
|
9
|
+
"registration", "ssh-publickey", "machine-identity", "linux", "node-runtime",
|
|
10
|
+
"deployment-root", "disk-space", "memory", "docker-daemon", "compose-version", "docker-data-root",
|
|
11
|
+
"least-privilege", "time-sync", "firewall", "egress", "backup-restore",
|
|
12
|
+
]);
|
|
13
|
+
const profiles = new Set(["base", "compose", "native"]);
|
|
14
|
+
const statuses = new Set(["pass", "fail", "warn", "unknown", "not-applicable"]);
|
|
15
|
+
|
|
16
|
+
function version(v) {
|
|
17
|
+
const m = typeof v === "string" && /(?:^|\s)v?(\d+)\.(\d+)\.(\d+)(?:\s|$|[-+])/.exec(v);
|
|
18
|
+
return m ? m.slice(1).map(Number) : null;
|
|
19
|
+
}
|
|
20
|
+
function atLeast(v, min) {
|
|
21
|
+
const found = version(v);
|
|
22
|
+
if (!found) return null;
|
|
23
|
+
for (let i = 0; i < 3; i++) if (found[i] !== min[i]) return found[i] > min[i];
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
function nodeSupported(v) {
|
|
27
|
+
const parsed = version(v);
|
|
28
|
+
return parsed ? atLeast(v, [22, 22, 3]) && parsed[0] < 25 : null;
|
|
29
|
+
}
|
|
30
|
+
function result(id, value, required, detail) {
|
|
31
|
+
return { id, status: value == null ? "unknown" : value ? "pass" : "fail", required, detail };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assessServer(host, { profile = "base", inventory = null, keyLogin = null,
|
|
35
|
+
minFreeMiB = 1024, minMemoryMiB = 256 } = {}) {
|
|
36
|
+
if (!profiles.has(profile)) throw new OpsError("profile must be base, compose or native");
|
|
37
|
+
for (const n of [minFreeMiB, minMemoryMiB]) if (!Number.isSafeInteger(n) || n < 1) throw new OpsError("resource thresholds must be positive integer MiB");
|
|
38
|
+
const inv = inventory;
|
|
39
|
+
const dc = inv?.docker_control;
|
|
40
|
+
const requiredRuntime = profile !== "base";
|
|
41
|
+
const disk = inv?.diagnostics?.disks?.[host.root]?.free;
|
|
42
|
+
const memory = inv?.diagnostics?.memory?.MemAvailable;
|
|
43
|
+
const checks = [
|
|
44
|
+
result("registration", true, true, "Registered resource; not proof of connectivity"),
|
|
45
|
+
host.transport === "ssh" ? result("ssh-publickey", keyLogin, true, "Strict pinned host key; publickey only; no password fallback")
|
|
46
|
+
: { id: "ssh-publickey", status: "not-applicable", required: false, detail: "Explicit local target; not the controller by inference" },
|
|
47
|
+
result("machine-identity", inv ? inv.identity === host.identity : null, true, "Observed machine identity must equal registered identity"),
|
|
48
|
+
result("linux", inv ? inv.platform === "linux" : null, requiredRuntime, "This readiness profile covers Linux; other platforms retain legacy support"),
|
|
49
|
+
result("node-runtime", nodeSupported(inv?.tools?.node?.version), requiredRuntime, "Node >=22.22.3 <25; missing Node does not prevent initial registration"),
|
|
50
|
+
result("deployment-root", inv?.snapshots?.[host.root] ? inv.snapshots[host.root].kind === "directory" : null, requiredRuntime, "Dedicated root exists without symlink traversal; does not prove write permission"),
|
|
51
|
+
result("disk-space", Number.isFinite(disk) ? disk >= minFreeMiB * 1024 * 1024 : null, requiredRuntime, `At least ${minFreeMiB} MiB free on root filesystem; project capacity still requires sizing`),
|
|
52
|
+
result("memory", Number.isFinite(memory) ? memory >= minMemoryMiB * 1024 * 1024 : null, requiredRuntime, `At least ${minMemoryMiB} MiB available; not a workload capacity guarantee`),
|
|
53
|
+
result("docker-daemon", dc ? dc.status === "observed" && typeof dc.endpoint === "string" && dc.endpoint.startsWith("unix://") : null, profile === "compose", "Reachable local Linux Docker daemon; remote Docker contexts are not this server"),
|
|
54
|
+
result("compose-version", atLeast(dc?.compose_version, [2, 30, 0]), profile === "compose", "Compose >=2.30 for raw env_file; container health remains a deployment check"),
|
|
55
|
+
result("docker-data-root", dc?.status === "observed" ? dc.data_root === posix.join(host.root, "_runtime/docker") : null, profile === "compose", "Existing Docker data must never be silently moved"),
|
|
56
|
+
...["least-privilege", "time-sync", "firewall", "egress", "backup-restore"].map((id) => ({
|
|
57
|
+
id, status: "unknown", required: false, detail: "Requires requirement-specific evidence; no automatic mutation or blanket hardening",
|
|
58
|
+
})),
|
|
59
|
+
];
|
|
60
|
+
return checks;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readiness(checks, checkedAt, { at = new Date(), staleHours = 24 } = {}) {
|
|
64
|
+
if (!Number.isFinite(staleHours) || staleHours <= 0) throw new OpsError("staleHours must be positive");
|
|
65
|
+
const timestamp = Date.parse(checkedAt);
|
|
66
|
+
if (!Number.isFinite(timestamp) || !Number.isFinite(at.getTime())) return "unknown";
|
|
67
|
+
if (timestamp > at.getTime() + 60000) return "unknown";
|
|
68
|
+
if (at.getTime() - timestamp > staleHours * 3600000) return "stale";
|
|
69
|
+
if (!Array.isArray(checks) || !checks.length) return "unknown";
|
|
70
|
+
const required = checks.filter((c) => c.required);
|
|
71
|
+
if (!required.length) return "unknown";
|
|
72
|
+
if (required.some((c) => c.status === "fail")) return "blocked";
|
|
73
|
+
if (required.some((c) => c.status !== "pass")) return "unknown";
|
|
74
|
+
return "ready";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validateCheck(report, host) {
|
|
78
|
+
if (report?.schema_version !== 1 || report.artifact !== "ops-server-check" || report.host_id !== host.host_id
|
|
79
|
+
|| !profiles.has(report.profile) || !Number.isFinite(Date.parse(report.checked_at)) || !Array.isArray(report.checks)) {
|
|
80
|
+
throw new OpsError("invalid server check report");
|
|
81
|
+
}
|
|
82
|
+
const found = new Set();
|
|
83
|
+
for (const c of report.checks) {
|
|
84
|
+
if (!CHECK_IDS.includes(c.id) || found.has(c.id) || !statuses.has(c.status) || typeof c.required !== "boolean") throw new OpsError("invalid or duplicate check item");
|
|
85
|
+
found.add(c.id);
|
|
86
|
+
}
|
|
87
|
+
if (found.size !== CHECK_IDS.length) throw new OpsError("incomplete server checklist");
|
|
88
|
+
if (!/^[a-f0-9]{64}$/.test(report.host_digest || "")) throw new OpsError("missing check host digest");
|
|
89
|
+
return report;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function latestCheck(state, host) {
|
|
93
|
+
identifier(host.host_id);
|
|
94
|
+
const folder = join(state, "records", "servers", host.host_id, "checks");
|
|
95
|
+
const pointer = join(folder, "latest.json");
|
|
96
|
+
noSymlinks(pointer);
|
|
97
|
+
if (!existsSync(pointer)) return null;
|
|
98
|
+
const p = readBoundedJson(pointer);
|
|
99
|
+
identifier(p.check_id, "check_id");
|
|
100
|
+
const value = readBoundedJson(join(folder, p.check_id + ".json"));
|
|
101
|
+
if (digest(value) !== p.digest) throw new OpsError("server check receipt digest mismatch");
|
|
102
|
+
validateCheck(value, host);
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function checkServer(state, serverId, options = {}, adapter = null) {
|
|
107
|
+
statePath(state);
|
|
108
|
+
identifier(serverId, "server");
|
|
109
|
+
const registry = readRegistry(state);
|
|
110
|
+
const host = registry.hosts[serverId];
|
|
111
|
+
if (!host) throw new OpsError("unknown server; enroll through S first");
|
|
112
|
+
if (host.platform !== "linux") throw new OpsError("server-check currently covers Linux only; use the existing platform-specific probe for other servers");
|
|
113
|
+
// Reject malformed options before performing any network reads.
|
|
114
|
+
assessServer(host, options);
|
|
115
|
+
const pinnedTarget = targetDigest(host);
|
|
116
|
+
if (!adapter) {
|
|
117
|
+
const transport = await import("./transport.mjs");
|
|
118
|
+
adapter = { call: transport.call, sshArgv: transport.sshArgv, spawn: spawnSync };
|
|
119
|
+
}
|
|
120
|
+
let keyLogin = null, inventory = null;
|
|
121
|
+
if (host.transport === "ssh") {
|
|
122
|
+
const argv = adapter.sshArgv(host.connection);
|
|
123
|
+
argv.push("-o", "PreferredAuthentications=publickey", "-o", "PasswordAuthentication=no",
|
|
124
|
+
"-o", "KbdInteractiveAuthentication=no", "--", host.connection.username + "@" + host.connection.hostname, "true");
|
|
125
|
+
const p = adapter.spawn(argv[0], argv.slice(1), { encoding: "utf8", timeout: 30000, maxBuffer: 1024 * 1024, windowsHide: true });
|
|
126
|
+
keyLogin = !p.error && p.status === 0;
|
|
127
|
+
}
|
|
128
|
+
if (host.transport === "local" || keyLogin) {
|
|
129
|
+
try { inventory = adapter.call(host, { action: "probe", include_docker: true, disk_roots: [host.root], paths: [host.root] }, { timeout: 180 }); }
|
|
130
|
+
catch { /* A failed read is not evidence of health; keep inventory-dependent checks unknown. */ }
|
|
131
|
+
}
|
|
132
|
+
const report = {
|
|
133
|
+
schema_version: 1, artifact: "ops-server-check", host_id: serverId,
|
|
134
|
+
profile: options.profile ?? "base", checked_at: now(), host_digest: pinnedTarget,
|
|
135
|
+
checks: assessServer(host, { ...options, keyLogin, inventory }),
|
|
136
|
+
};
|
|
137
|
+
validateCheck(report, host);
|
|
138
|
+
// Refuse to attribute observations to a changed endpoint or identity.
|
|
139
|
+
if (targetDigest(readRegistry(state).hosts[serverId]) !== report.host_digest) throw new OpsError("server changed while checking; do not publish misattributed evidence");
|
|
140
|
+
const checkId = newId("check");
|
|
141
|
+
const folder = join(state, "records", "servers", serverId, "checks");
|
|
142
|
+
withLock(join(state, "records", ".locks", "check-" + serverId), { operation: "server-check" }, () => {
|
|
143
|
+
writeJson(join(folder, checkId + ".json"), report, { exclusive: true });
|
|
144
|
+
writeJson(join(folder, "latest.json"), { check_id: checkId, digest: digest(report) });
|
|
145
|
+
});
|
|
146
|
+
const status = readiness(report.checks, report.checked_at);
|
|
147
|
+
return { status, server_id: serverId, profile: report.profile, report: join(folder, checkId + ".json"), checks: report.checks,
|
|
148
|
+
note: "Read-only baseline, not application readiness, live monitoring or production acceptance" };
|
|
149
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/** One user-authorized task, frozen specifications, legacy digest-bound plans, no automatic retry. */
|
|
2
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative as pathRelative, resolve } from "node:path";
|
|
4
|
+
import { atomicWrite, digest, exact, identifier, noSymlinks, now, OpsError, redact, withLock, writeJson } from "./core.mjs";
|
|
5
|
+
import { fileDigest, ids, readBoundedJson, readRegistry, route, statePath, targetDigest, containedPath } from "./workspace.mjs";
|
|
6
|
+
|
|
7
|
+
const DESTRUCTIVE_ACK = "I-APPROVE-THIS-EXACT-DESTRUCTIVE-TASK";
|
|
8
|
+
const MAX_INPUT_BYTES = 256 * 1024 * 1024;
|
|
9
|
+
const fields = new Set(["schema_version", "task_id", "title", "plan", "valid_for_hours", "scope", "input_paths", "steps"]);
|
|
10
|
+
const scopeFields = new Set(["scope", "server_ids", "project_ids", "related_project_ids", "allow_policy_changes", "allow_public_ingress", "allow_destructive"]);
|
|
11
|
+
|
|
12
|
+
async function runtime() {
|
|
13
|
+
const [model, planner, execution] = await Promise.all([import("./model.mjs"), import("./planner.mjs"), import("./execution.mjs")]);
|
|
14
|
+
return { ...model, compilePlan: planner.compilePlan, engineDigest: execution.engineDigest, approval: execution.approval, apply: execution.apply };
|
|
15
|
+
}
|
|
16
|
+
function taskFolder(state, taskId) { return join(statePath(state), "records", "tasks", identifier(taskId, "task_id")); }
|
|
17
|
+
function active(task, at = Date.now()) {
|
|
18
|
+
if (!Number.isFinite(Date.parse(task.expires_at)) || at >= Date.parse(task.expires_at)) throw new OpsError("task authorization expired; form a new bounded task");
|
|
19
|
+
}
|
|
20
|
+
export { targetDigest } from "./workspace.mjs";
|
|
21
|
+
function allowedProjects(scope) { return new Set([...scope.project_ids, ...scope.related_project_ids]); }
|
|
22
|
+
|
|
23
|
+
export function validateRequest(request, registry) {
|
|
24
|
+
exact(request, fields, fields, "task request");
|
|
25
|
+
if (request.schema_version !== 1) throw new OpsError("task request schema_version must be 1");
|
|
26
|
+
identifier(request.task_id, "task_id");
|
|
27
|
+
if (typeof request.title !== "string" || request.title.trim().length < 3 || request.title.length > 200) throw new OpsError("task needs a meaningful title (3..200 characters)");
|
|
28
|
+
if (typeof request.plan !== "string" || request.plan.trim().length < 40 || request.plan.length > 32000) throw new OpsError("task needs a detailed, already-disclosed Plan Mode description (40..32000 characters)");
|
|
29
|
+
if (!Number.isInteger(request.valid_for_hours) || request.valid_for_hours < 1 || request.valid_for_hours > 24) throw new OpsError("valid_for_hours must be 1..24");
|
|
30
|
+
const s = request.scope;
|
|
31
|
+
exact(s, scopeFields, scopeFields, "task scope");
|
|
32
|
+
if (!["server", "project"].includes(s.scope)) throw new OpsError("task execution covers H/D only; controller initialization and SSH enrollment retain their own entry");
|
|
33
|
+
ids(s.server_ids, "server_ids");
|
|
34
|
+
// A task may sequence several H steps and include explicitly named impact hosts;
|
|
35
|
+
// the standalone H routing command still selects one primary server.
|
|
36
|
+
route(registry, { scope: s.scope, server_ids: s.scope === "server" ? s.server_ids.slice(0, 1) : s.server_ids, project_ids: s.project_ids });
|
|
37
|
+
for (const id of s.server_ids) if (!Object.hasOwn(registry.hosts, id)) throw new OpsError(`unknown task server: ${id}`);
|
|
38
|
+
ids(s.related_project_ids, "related_project_ids");
|
|
39
|
+
for (const id of s.related_project_ids) if (!Object.hasOwn(registry.projects, id) || s.project_ids.includes(id)) throw new OpsError("related projects must be distinct registered impact/dependency IDs");
|
|
40
|
+
for (const key of ["allow_policy_changes", "allow_public_ingress", "allow_destructive"]) if (typeof s[key] !== "boolean") throw new OpsError(`${key} must be explicit boolean`);
|
|
41
|
+
if (!Array.isArray(request.input_paths) || new Set(request.input_paths).size !== request.input_paths.length) throw new OpsError("input_paths must be distinct absolute files/directories");
|
|
42
|
+
if (!Array.isArray(request.steps) || request.steps.length < 1 || request.steps.length > 20) throw new OpsError("task must contain 1..20 ordered steps");
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const step of request.steps) {
|
|
45
|
+
exact(step, new Set(["id", "title", "spec"]), new Set(["id", "title", "spec"]), "task step");
|
|
46
|
+
identifier(step.id, "step.id");
|
|
47
|
+
if (seen.has(step.id)) throw new OpsError("duplicate task step ID");
|
|
48
|
+
seen.add(step.id);
|
|
49
|
+
if (typeof step.title !== "string" || !step.title.trim() || step.title.length > 200) throw new OpsError("step title must be 1..200 characters");
|
|
50
|
+
assertSpecScope(step.spec, s, registry);
|
|
51
|
+
}
|
|
52
|
+
return request;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function inScopeDeployment(dep, scope, label) {
|
|
56
|
+
if (!dep || !scope.server_ids.includes(dep.host_id) || !allowedProjects(scope).has(dep.project_id)) throw new OpsError(`deployment/consumer outside task scope: ${label}`);
|
|
57
|
+
}
|
|
58
|
+
export function assertSpecScope(spec, scope, registry) {
|
|
59
|
+
if (!spec || typeof spec !== "object" || !["H", "D"].includes(spec.worker)) throw new OpsError("task step must use the existing H or D spec contract");
|
|
60
|
+
if (spec.worker === "H" && (["deployments", "allocations", "bindings", "provision", "retire_deployments", "retire_bindings"].some((k) => (spec[k] || []).length) || (spec.resource_updates?.projects || []).length)) throw new OpsError("H cannot create or retire project resources; use a separate D step");
|
|
61
|
+
if (spec.worker === "D" && ((spec.host_actions || []).length || (spec.resource_updates?.hosts || []).length || spec.public_ingress !== undefined)) throw new OpsError("D cannot configure server-level resources; use a separate H prerequisite step");
|
|
62
|
+
if (scope.scope === "server" && spec.worker !== "H") throw new OpsError("server task cannot silently become project deployment; select D explicitly");
|
|
63
|
+
if ((["migrate", "uninstall"].includes(spec.operation) || (spec.retire_deployments || []).length || (spec.host_actions || []).some((a) => a.kind === "purge-quarantine")) && !scope.allow_destructive) {
|
|
64
|
+
throw new OpsError("destructive/migration scope not authorized");
|
|
65
|
+
}
|
|
66
|
+
if (spec.plaintext_documentation !== undefined && spec.plaintext_documentation !== registry.policies.plaintext_documentation && !scope.allow_policy_changes) throw new OpsError("policy change outside task scope");
|
|
67
|
+
if (spec.public_ingress !== undefined && digest(spec.public_ingress) !== digest(registry.public_ingress ?? null) && !scope.allow_public_ingress) throw new OpsError("public ingress change outside task scope");
|
|
68
|
+
const projects = allowedProjects(scope);
|
|
69
|
+
const deployments = { ...registry.deployments, ...Object.fromEntries((spec.deployments || []).map((d) => [d.deployment_id, d])) };
|
|
70
|
+
const checkHost = (id) => { if (!scope.server_ids.includes(id)) throw new OpsError(`server outside task scope: ${id}`); };
|
|
71
|
+
for (const id of spec.hosts || []) checkHost(id);
|
|
72
|
+
for (const h of spec.resource_updates?.hosts || []) {
|
|
73
|
+
checkHost(h.host_id);
|
|
74
|
+
if (targetDigest(h) !== targetDigest(registry.hosts[h.host_id])) throw new OpsError("connection/identity/root changes belong to a new S task, not delegated H/D approval");
|
|
75
|
+
}
|
|
76
|
+
for (const action of spec.host_actions || []) checkHost(action.host_id);
|
|
77
|
+
for (const d of spec.deployments || []) inScopeDeployment(d, scope, d.deployment_id);
|
|
78
|
+
for (const p of spec.resource_updates?.projects || []) if (!projects.has(p.project_id)) throw new OpsError(`project outside task scope: ${p.project_id}`);
|
|
79
|
+
for (const id of [...(spec.retire_deployments || []), ...(spec.acknowledged_consumers || [])]) inScopeDeployment(deployments[id], scope, id);
|
|
80
|
+
// Inspect resource references, not arbitrary project JSON/env keys named host_id.
|
|
81
|
+
const ingressHosts = (v) => {
|
|
82
|
+
if (Array.isArray(v)) for (const x of v) ingressHosts(x);
|
|
83
|
+
else if (v && typeof v === "object") for (const [key, x] of Object.entries(v)) {
|
|
84
|
+
if ((key === "host_id" || key.endsWith("_host_id")) && typeof x === "string") checkHost(x);
|
|
85
|
+
else ingressHosts(x);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
ingressHosts(spec.public_ingress);
|
|
89
|
+
const allocations = { ...registry.allocations, ...Object.fromEntries((spec.allocations || []).map((a) => [a.allocation_id, a])) };
|
|
90
|
+
const bindings = { ...registry.bindings, ...Object.fromEntries((spec.bindings || []).map((b) => [b.binding_id, b])) };
|
|
91
|
+
const allocation = (id) => {
|
|
92
|
+
const a = allocations[id];
|
|
93
|
+
if (!a || !projects.has(a.owner_project_id) || (a.shared_owners || []).some((p) => !projects.has(p))) throw new OpsError(`allocation owner outside task scope: ${id}`);
|
|
94
|
+
inScopeDeployment(deployments[a.provider_deployment_id], scope, a.provider_deployment_id);
|
|
95
|
+
};
|
|
96
|
+
const binding = (id) => {
|
|
97
|
+
const b = bindings[id];
|
|
98
|
+
if (!b) throw new OpsError(`unknown binding: ${id}`);
|
|
99
|
+
inScopeDeployment(deployments[b.consumer_deployment_id], scope, b.consumer_deployment_id);
|
|
100
|
+
if (b.provider_deployment_id) inScopeDeployment(deployments[b.provider_deployment_id], scope, b.provider_deployment_id);
|
|
101
|
+
if (b.allocation_id) allocation(b.allocation_id);
|
|
102
|
+
};
|
|
103
|
+
for (const a of spec.allocations || []) allocation(a.allocation_id);
|
|
104
|
+
for (const p of spec.provision || []) allocation(p.allocation_id);
|
|
105
|
+
for (const b of spec.bindings || []) binding(b.binding_id);
|
|
106
|
+
for (const id of spec.retire_bindings || []) binding(id);
|
|
107
|
+
// Include known dependency/impact closure before the planner performs remote reads.
|
|
108
|
+
const touched = new Set([...(spec.deployments || []).map((d) => d.deployment_id), ...(spec.retire_deployments || [])]);
|
|
109
|
+
const hostActions = new Set((spec.host_actions || []).map((a) => a.host_id));
|
|
110
|
+
for (const d of Object.values(registry.deployments)) if (hostActions.has(d.host_id) && d.status !== "retired") touched.add(d.deployment_id);
|
|
111
|
+
let changed = true;
|
|
112
|
+
while (changed) {
|
|
113
|
+
changed = false;
|
|
114
|
+
for (const b of Object.values({ ...registry.bindings, ...Object.fromEntries((spec.bindings || []).map((b) => [b.binding_id, b])) })) {
|
|
115
|
+
if (b.status === "retired" || (!touched.has(b.consumer_deployment_id) && !touched.has(b.provider_deployment_id))) continue;
|
|
116
|
+
for (const id of [b.consumer_deployment_id, b.provider_deployment_id].filter(Boolean)) if (!touched.has(id)) { touched.add(id); changed = true; }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const id of touched) inScopeDeployment(deployments[id], scope, id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function pinInputs(paths) {
|
|
123
|
+
const files = {};
|
|
124
|
+
let bytes = 0;
|
|
125
|
+
const walk = (p) => {
|
|
126
|
+
noSymlinks(p, { allowMissing: false });
|
|
127
|
+
const st = statSync(p);
|
|
128
|
+
if (st.isDirectory()) {
|
|
129
|
+
for (const name of readdirSync(p).sort()) walk(join(p, name));
|
|
130
|
+
} else if (st.isFile()) {
|
|
131
|
+
if (Object.hasOwn(files, p)) return;
|
|
132
|
+
bytes += st.size;
|
|
133
|
+
if (Object.keys(files).length >= 10000 || bytes > MAX_INPUT_BYTES) throw new OpsError("task inputs exceed 10000 files/256 MiB; use a reviewed bounded artifact tree");
|
|
134
|
+
files[p] = fileDigest(p);
|
|
135
|
+
} else throw new OpsError("task input must be a regular file or directory");
|
|
136
|
+
};
|
|
137
|
+
for (const p of paths) {
|
|
138
|
+
if (typeof p !== "string" || !isAbsolute(p) || p.split(/[\\/]/).includes("..")) throw new OpsError("task input paths must be absolute without traversal");
|
|
139
|
+
walk(resolve(p));
|
|
140
|
+
}
|
|
141
|
+
return files;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function markdown(task) {
|
|
145
|
+
return `# ${redact(task.request.title, [])}\n\n` +
|
|
146
|
+
`任务:\`${task.request.task_id}\`;控制端:\`${task.controller_id}\`;有效期:${task.expires_at}。\n\n` +
|
|
147
|
+
`范围:${task.request.scope.scope};服务器:${task.request.scope.server_ids.join(", ")};项目:${task.request.scope.project_ids.join(", ") || "无"};影响/依赖项目:${task.request.scope.related_project_ids.join(", ") || "无"}。\n\n` +
|
|
148
|
+
`## Plan Mode\n\n${redact(task.request.plan, [])}\n\n` +
|
|
149
|
+
`## 固定顺序与精确规格\n\n` + task.request.steps.map((s, i) =>
|
|
150
|
+
`${i + 1}. ${redact(s.title, [])} — worker=${s.spec.worker},operation=${s.spec.operation},spec SHA256=${digest(s.spec)};[受限规格](specs/${s.id}.json)。`
|
|
151
|
+
).join("\n") + `\n\n## 授权边界\n\n此文件不是授权。明确实施请求且已展示上述计划后,入口可记录一次原始任务确认,不逐步骤再问。运行器只为这些冻结规格产生的范围内计划写入关联任务的机械批准;不声称用户逐一确认了未来的计划摘要。\n\n` +
|
|
152
|
+
`策略变更:${task.request.scope.allow_policy_changes};公网映射:${task.request.scope.allow_public_ingress};破坏性/迁移:${task.request.scope.allow_destructive}。\n\n` +
|
|
153
|
+
`输入文件:${Object.keys(task.input_digests).length};执行器 SHA256:${task.engine_digest}。连接、凭据账本、输入文件、执行器漂移或过期即停止。\n\n` +
|
|
154
|
+
`失败/unknown/docs_pending 不自动重试、不自动迁移回滚。原 plan/approval/journal/双边回执仍是执行事实源。\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function prepareTask(state, request, adapter = null) {
|
|
158
|
+
statePath(state);
|
|
159
|
+
adapter ??= await runtime();
|
|
160
|
+
const registry = adapter.load(state);
|
|
161
|
+
readRegistry(state);
|
|
162
|
+
validateRequest(request, registry);
|
|
163
|
+
for (const step of request.steps) adapter.validate(step.spec, "spec");
|
|
164
|
+
const task = {
|
|
165
|
+
schema_version: 1, artifact: "ops-task", controller_id: registry.controller.controller_id,
|
|
166
|
+
created_at: now(), expires_at: new Date(Date.now() + request.valid_for_hours * 3600000).toISOString(),
|
|
167
|
+
registry_revision: registry.revision, request: structuredClone(request),
|
|
168
|
+
engine_digest: adapter.engineDigest(), ledger_digest: digest(adapter.ledgerLoad(state)),
|
|
169
|
+
targets: Object.fromEntries(request.scope.server_ids.map((id) => [id, targetDigest(registry.hosts[id])])),
|
|
170
|
+
input_digests: pinInputs(request.input_paths),
|
|
171
|
+
};
|
|
172
|
+
const folder = taskFolder(state, request.task_id);
|
|
173
|
+
return withLock(join(state, "records", ".locks", "task-" + request.task_id), { operation: "task-plan" }, () => {
|
|
174
|
+
if (existsSync(folder)) throw new OpsError("task ID already exists; never overwrite task evidence");
|
|
175
|
+
for (const step of request.steps) writeJson(join(folder, "specs", step.id + ".json"), step.spec, { exclusive: true });
|
|
176
|
+
atomicWrite(join(folder, "PLAN.md"), markdown(task), 0o600, { exclusive: true });
|
|
177
|
+
writeJson(join(folder, "summary.json"), { schema_version: 1, artifact: "ops-task-summary", task_id: request.task_id,
|
|
178
|
+
scope: request.scope.scope, server_ids: request.scope.server_ids, project_ids: request.scope.project_ids,
|
|
179
|
+
related_project_ids: request.scope.related_project_ids, step_ids: request.steps.map((s) => s.id), created_at: task.created_at }, { exclusive: true });
|
|
180
|
+
// Commit marker written last. A crashed preparation is preserved, never auto-repaired.
|
|
181
|
+
writeJson(join(folder, "task.json"), task, { exclusive: true });
|
|
182
|
+
return { status: "prepared-not-authorized", task_id: request.task_id, task_digest: digest(task), plan: join(folder, "PLAN.md"), steps: request.steps.length };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function authorizeTask(state, taskId, expected, by, statement, ackDestructive = null) {
|
|
187
|
+
const folder = taskFolder(state, taskId);
|
|
188
|
+
const task = readBoundedJson(join(folder, "task.json"));
|
|
189
|
+
if (digest(task) !== expected) throw new OpsError("task digest mismatch; reread the disclosed task plan");
|
|
190
|
+
active(task);
|
|
191
|
+
validateRequest(task.request, readRegistry(state));
|
|
192
|
+
if (typeof by !== "string" || !by.trim() || typeof statement !== "string" || statement.trim().length < 8) throw new OpsError("identified approver and explicit original task confirmation are required");
|
|
193
|
+
if (task.request.scope.allow_destructive && ackDestructive !== DESTRUCTIVE_ACK) throw new OpsError("destructive task requires an independently explicit exact-task acknowledgement");
|
|
194
|
+
const value = { schema_version: 1, artifact: "ops-task-authorization", task_id: taskId, task_digest: expected,
|
|
195
|
+
approved_by: by, statement, approved_at: now(), decision: "approved", destructive_ack: ackDestructive };
|
|
196
|
+
return withLock(join(state, "records", ".locks", "task-" + taskId), { operation: "task-authorize" }, () => {
|
|
197
|
+
writeJson(join(folder, "authorization.json"), value, { exclusive: true });
|
|
198
|
+
return { status: "authorized", task_id: taskId, task_digest: expected, note: "No per-step conversational re-confirmation inside this frozen task" };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function assertPlanScope(plan, task, registry) {
|
|
203
|
+
const s = task.request.scope, projects = allowedProjects(s);
|
|
204
|
+
if (plan.controller_id !== task.controller_id || plan.engine_digest !== task.engine_digest) throw new OpsError("compiled plan controller/engine differs from authorized task");
|
|
205
|
+
for (const [id, h] of Object.entries(plan.hosts || {})) if (!s.server_ids.includes(id) || targetDigest(h) !== task.targets[id]) throw new OpsError(`compiled plan target outside authorization: ${id}`);
|
|
206
|
+
const scope = plan.registry_scope;
|
|
207
|
+
if (!scope) throw new OpsError("compiled plan has no registry scope");
|
|
208
|
+
for (const id of scope.hosts || []) if (!s.server_ids.includes(id)) throw new OpsError(`catalog server outside task: ${id}`);
|
|
209
|
+
for (const id of scope.projects || []) if (!projects.has(id)) throw new OpsError(`catalog project outside task: ${id}`);
|
|
210
|
+
for (const id of [...(scope.deployments || []), ...(plan.document_targets || []), ...(plan.affected_consumers || [])]) {
|
|
211
|
+
inScopeDeployment(plan.registry_after.deployments[id] || registry.deployments[id], s, id);
|
|
212
|
+
}
|
|
213
|
+
for (const id of scope.allocations || []) {
|
|
214
|
+
const a = plan.registry_after.allocations?.[id] || registry.allocations[id];
|
|
215
|
+
if (!a || !projects.has(a.owner_project_id) || (a.shared_owners || []).some((p) => !projects.has(p))) throw new OpsError("compiled allocation owner outside task");
|
|
216
|
+
inScopeDeployment(plan.registry_after.deployments[a.provider_deployment_id] || registry.deployments[a.provider_deployment_id], s, a.provider_deployment_id);
|
|
217
|
+
}
|
|
218
|
+
for (const id of scope.bindings || []) {
|
|
219
|
+
const b = plan.registry_after.bindings?.[id] || registry.bindings[id];
|
|
220
|
+
if (!b) throw new OpsError("compiled binding not found");
|
|
221
|
+
for (const did of [b.consumer_deployment_id, b.provider_deployment_id].filter(Boolean)) inScopeDeployment(plan.registry_after.deployments[did] || registry.deployments[did], s, did);
|
|
222
|
+
}
|
|
223
|
+
// A compiler omission must not hide an unrelated catalog mutation.
|
|
224
|
+
for (const group of ["hosts", "projects", "deployments", "allocations", "bindings"]) {
|
|
225
|
+
for (const id of new Set([...Object.keys(registry[group]), ...Object.keys(plan.registry_after[group] || {})])) {
|
|
226
|
+
if (digest(registry[group][id] ?? null) !== digest(plan.registry_after[group]?.[id] ?? null) && !(scope[group] || []).includes(id)) throw new OpsError(`unscoped compiled catalog change: ${group}/${id}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if ((scope.policies || digest(registry.policies) !== digest(plan.registry_after.policies)) && !s.allow_policy_changes) throw new OpsError("compiled plan changes unauthorized policies");
|
|
230
|
+
if ((scope.public_ingress || digest(registry.public_ingress ?? null) !== digest(plan.registry_after.public_ingress ?? null)) && !s.allow_public_ingress) throw new OpsError("compiled plan changes unauthorized public ingress");
|
|
231
|
+
for (const op of plan.operations || []) {
|
|
232
|
+
if (!s.server_ids.includes(op.host_id)) throw new OpsError("operation target outside task");
|
|
233
|
+
if (op.deployment_id) inScopeDeployment(plan.registry_after.deployments[op.deployment_id], s, op.deployment_id);
|
|
234
|
+
if (op.kind === "purge-quarantine" && !s.allow_destructive) throw new OpsError("unauthorized purge operation");
|
|
235
|
+
}
|
|
236
|
+
for (const [p, d] of Object.entries(plan.source_digests || {})) if (task.input_digests[resolve(p)] !== d) throw new OpsError(`unreviewed or changed local source input: ${p}; pin it in input_paths before authorization`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function preflight(state, task, adapter, first = false) {
|
|
240
|
+
active(task);
|
|
241
|
+
const registry = adapter.load(state);
|
|
242
|
+
if (registry.controller.controller_id !== task.controller_id) throw new OpsError("task controller mismatch");
|
|
243
|
+
if (first && registry.revision !== task.registry_revision) throw new OpsError("catalog changed after task preparation; form a new disclosed task");
|
|
244
|
+
if (adapter.engineDigest() !== task.engine_digest) throw new OpsError("task executor drifted");
|
|
245
|
+
if (digest(adapter.ledgerLoad(state)) !== task.ledger_digest) throw new OpsError("credential ledger drifted after task authorization");
|
|
246
|
+
for (const [id, expected] of Object.entries(task.targets)) if (!registry.hosts[id] || targetDigest(registry.hosts[id]) !== expected) throw new OpsError(`task connection/identity drift: ${id}`);
|
|
247
|
+
if (digest(pinInputs(task.request.input_paths)) !== digest(task.input_digests)) throw new OpsError("task input files changed, disappeared or were added");
|
|
248
|
+
return registry;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function runTask(state, taskId, adapter = null) {
|
|
252
|
+
const folder = taskFolder(state, taskId);
|
|
253
|
+
adapter ??= await runtime();
|
|
254
|
+
const task = readBoundedJson(join(folder, "task.json"));
|
|
255
|
+
const auth = readBoundedJson(join(folder, "authorization.json"));
|
|
256
|
+
if (auth.artifact !== "ops-task-authorization" || auth.decision !== "approved" || auth.task_id !== taskId || auth.task_digest !== digest(task)) throw new OpsError("missing or mismatched task authorization");
|
|
257
|
+
if (task.request.task_id !== taskId) throw new OpsError("task path/identity mismatch");
|
|
258
|
+
return withLock(join(state, "records", ".locks", "task-" + taskId), { operation: "task-run", task_id: taskId }, () => {
|
|
259
|
+
if (existsSync(join(folder, "started.json"))) throw new OpsError("task already started; inspect its progress and original run receipts, never automatically replay it");
|
|
260
|
+
const initial = preflight(state, task, adapter, true);
|
|
261
|
+
validateRequest(task.request, initial);
|
|
262
|
+
writeJson(join(folder, "started.json"), { task_digest: digest(task), at: now() }, { exclusive: true });
|
|
263
|
+
const progress = { schema_version: 1, artifact: "ops-task-result", task_id: taskId, status: "running", steps: [], started_at: now(), updated_at: now() };
|
|
264
|
+
let phase = "planning";
|
|
265
|
+
try {
|
|
266
|
+
for (const step of task.request.steps) {
|
|
267
|
+
phase = "planning";
|
|
268
|
+
const registry = preflight(state, task, adapter);
|
|
269
|
+
assertSpecScope(step.spec, task.request.scope, registry);
|
|
270
|
+
const path = join(folder, "specs", step.id + ".json");
|
|
271
|
+
if (digest(readBoundedJson(path)) !== digest(step.spec)) throw new OpsError("frozen step specification drifted");
|
|
272
|
+
const compiled = adapter.compilePlan(state, path);
|
|
273
|
+
containedPath(state, compiled.plan_path);
|
|
274
|
+
const plan = readBoundedJson(compiled.plan_path);
|
|
275
|
+
identifier(compiled.run_id, "run_id");
|
|
276
|
+
if (compiled.run_id !== plan.run_id || plan.worker !== step.spec.worker || plan.operation !== step.spec.operation) throw new OpsError("compiled run identity/worker/operation mismatch");
|
|
277
|
+
if (digest(plan) !== compiled.plan_digest) throw new OpsError("compiled plan digest mismatch");
|
|
278
|
+
const row = { step_id: step.id, run_id: compiled.run_id, plan_digest: compiled.plan_digest,
|
|
279
|
+
plan_path: pathRelative(state, compiled.plan_path).replaceAll("\\", "/"), status: "planned-not-authorized" };
|
|
280
|
+
progress.steps.push(row);
|
|
281
|
+
progress.updated_at = now();
|
|
282
|
+
writeJson(join(folder, "progress.json"), progress);
|
|
283
|
+
assertPlanScope(plan, task, registry);
|
|
284
|
+
// Check inputs again after compilation and before delegating any approval.
|
|
285
|
+
preflight(state, task, adapter);
|
|
286
|
+
adapter.approval(state, compiled.run_id, compiled.plan_digest, "task:" + taskId,
|
|
287
|
+
`Delegated exact-spec authorization from task ${taskId}, digest ${auth.task_digest}; original approver ${auth.approved_by}.`);
|
|
288
|
+
row.status = "executing";
|
|
289
|
+
writeJson(join(folder, "progress.json"), progress);
|
|
290
|
+
preflight(state, task, adapter);
|
|
291
|
+
phase = "applying";
|
|
292
|
+
const result = adapter.apply(state, compiled.run_id);
|
|
293
|
+
row.status = result.status;
|
|
294
|
+
progress.updated_at = now();
|
|
295
|
+
writeJson(join(folder, "progress.json"), progress);
|
|
296
|
+
phase = "between-steps";
|
|
297
|
+
if (result.status !== "completed") {
|
|
298
|
+
progress.status = ["failed", "unknown", "docs_pending", "partial", "blocked"].includes(result.status) ? result.status : "blocked";
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (progress.status === "running") progress.status = "completed";
|
|
303
|
+
} catch (error) {
|
|
304
|
+
progress.status = phase === "applying" ? "unknown" : "blocked";
|
|
305
|
+
progress.error = redact(error.message || String(error), []).slice(0, 2000);
|
|
306
|
+
}
|
|
307
|
+
progress.updated_at = now();
|
|
308
|
+
progress.recovery = progress.status === "completed" ? "none" : "Inspect progress and the original run with inspect-run; no automatic resume, docs-sync or migration retry. New actions need a new bounded task.";
|
|
309
|
+
writeJson(join(folder, "progress.json"), progress);
|
|
310
|
+
writeJson(join(folder, "result.json"), progress, { exclusive: true });
|
|
311
|
+
return { ...progress, record: folder };
|
|
312
|
+
});
|
|
313
|
+
}
|