@namewta/speculo 1.0.16 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +17 -5
  2. package/dist/src/agent-files.d.ts +9 -0
  3. package/dist/src/agent-files.js +106 -0
  4. package/dist/src/agent-files.js.map +1 -0
  5. package/dist/src/cli.js +64 -14
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/doctor.d.ts +2 -0
  8. package/dist/src/doctor.js +120 -53
  9. package/dist/src/doctor.js.map +1 -1
  10. package/dist/src/external-files.d.ts +22 -0
  11. package/dist/src/external-files.js +110 -0
  12. package/dist/src/external-files.js.map +1 -0
  13. package/dist/src/index.d.ts +3 -0
  14. package/dist/src/index.js +29 -182
  15. package/dist/src/index.js.map +1 -1
  16. package/dist/src/paths.d.ts +12 -0
  17. package/dist/src/paths.js +57 -0
  18. package/dist/src/paths.js.map +1 -0
  19. package/dist/src/transaction.d.ts +27 -0
  20. package/dist/src/transaction.js +187 -0
  21. package/dist/src/transaction.js.map +1 -0
  22. package/dist/src/utils.js +4 -2
  23. package/dist/src/utils.js.map +1 -1
  24. package/dist/src/workflows.js +3 -4
  25. package/dist/src/workflows.js.map +1 -1
  26. package/package.json +6 -4
  27. package/template/.speculo/README.md +9 -3
  28. package/template/.speculo/refresh-contract.json +6 -2
  29. package/template/AGENTS.md +1 -1
  30. package/template/commands/docs-sync.md +13 -11
  31. package/template/skills/archive-and-consolidate/SKILL.md +2 -4
  32. package/template/skills/docs-sync/SKILL.md +22 -11
  33. package/template/skills/docs-sync/references/agents-contract.md +6 -4
  34. package/template/skills/docs-sync/references/document-lifecycle-contract.md +2 -0
  35. package/template/skills/docs-sync/references/entry-procedure.md +2 -17
  36. package/template/skills/docs-sync/references/git-state-contract.md +1 -1
  37. package/template/skills/docs-sync/references/mode-contract.md +21 -0
  38. package/template/skills/docs-sync/references/workflow-scope-contract.md +5 -3
  39. package/template/skills/engineering-standards-builder/SKILL.md +3 -1
  40. package/template/skills/engineering-standards-builder/scripts/validate-builder.mjs +5 -1
  41. package/template/skills/git-history-squash/SKILL.md +3 -3
  42. package/template/skills/github-npm-ops/SKILL.md +2 -4
  43. package/template/skills/source-code-zip/SKILL.md +0 -1
  44. package/template/skills/speculo-retro/SKILL.md +2 -4
  45. package/template/skills/upstream-fork-sync/SKILL.md +1 -3
  46. package/template/skills/writing-great-skills/SKILL.md +3 -3
  47. package/template/workflows/learning/A-archive/A-archive.md +2 -0
  48. package/template/workflows/learning/A-assess-and-plan/A-assess-and-plan.md +2 -0
  49. package/template/workflows/learning/C-consolidate/C-consolidate.md +2 -0
  50. package/template/workflows/learning/G-goal/G-goal.md +6 -0
  51. package/template/workflows/learning/G-goal/references/external-goal-runner.md +4 -0
  52. package/template/workflows/learning/H-homework/H-homework.md +2 -0
  53. package/template/workflows/learning/L-lesson/L-lesson.md +2 -0
  54. package/template/workflows/learning/Q-question/Q-question.md +2 -0
  55. package/template/workflows/learning/R-review/R-review.md +2 -0
  56. package/template/workflows/learning/README.md +4 -53
  57. package/template/workflows/learning/common/rules/artifact-layouts.md +46 -0
  58. package/template/workflows/learning/common/rules/consolidation-contract.md +5 -0
  59. package/template/workflows/learning/common/rules/homework-contract.md +3 -0
  60. package/template/workflows/learning/common/rules/lesson-contract.md +7 -0
  61. package/template/workflows/learning/common/skills/socratic-questioning/SKILL.md +1 -0
  62. package/template/workflows/learning/common/skills/topic-synthesis/SKILL.md +1 -0
  63. package/template/workflows/ops/D-project-deploy/D-project-deploy.md +11 -9
  64. package/template/workflows/ops/H-host-manage/H-host-manage.md +11 -10
  65. package/template/workflows/ops/I-initialize/I-initialize.md +7 -7
  66. package/template/workflows/ops/INDEX.md +20 -16
  67. package/template/workflows/ops/README.md +34 -12
  68. package/template/workflows/ops/S-server-connect/S-server-connect.md +28 -0
  69. package/template/workflows/ops/V-inventory-view/V-inventory-view.md +30 -0
  70. package/template/workflows/ops/_state/status.json +2 -1
  71. package/template/workflows/ops/common/CAPABILITIES.md +22 -15
  72. package/template/workflows/ops/common/USAGE.md +4 -0
  73. package/template/workflows/ops/common/WORKSPACE-USAGE.md +78 -0
  74. package/template/workflows/ops/common/examples/task-host.example.json +64 -0
  75. package/template/workflows/ops/common/rules/activation-and-memory.md +5 -3
  76. package/template/workflows/ops/common/rules/inventory-and-records.md +55 -0
  77. package/template/workflows/ops/common/rules/persistence-and-secrets.md +4 -2
  78. package/template/workflows/ops/common/rules/research-and-decisions.md +18 -0
  79. package/template/workflows/ops/common/rules/server-onboarding.md +21 -0
  80. package/template/workflows/ops/common/rules/workspace-and-authorization.md +39 -0
  81. package/template/workflows/ops/common/schemas/fleet-view.schema.json +487 -0
  82. package/template/workflows/ops/common/schemas/plan.schema.json +4 -0
  83. package/template/workflows/ops/common/schemas/server-check.schema.json +95 -0
  84. package/template/workflows/ops/common/schemas/spec.schema.json +4 -0
  85. package/template/workflows/ops/common/schemas/status.schema.json +4 -0
  86. package/template/workflows/ops/common/schemas/task-request.schema.json +138 -0
  87. package/template/workflows/ops/common/templates/FLEET.html +61 -0
  88. package/template/workflows/ops/common/tests/test_ops.mjs +33 -2
  89. package/template/workflows/ops/common/tests/test_ops_workspace.mjs +246 -0
  90. package/template/workflows/ops/common/tools/ops.mjs +5 -3
  91. package/template/workflows/ops/common/tools/opslib/core.mjs +1 -1
  92. package/template/workflows/ops/common/tools/opslib/docs.mjs +12 -7
  93. package/template/workflows/ops/common/tools/opslib/fleet.mjs +155 -0
  94. package/template/workflows/ops/common/tools/opslib/planner.mjs +7 -1
  95. package/template/workflows/ops/common/tools/opslib/server_checks.mjs +149 -0
  96. package/template/workflows/ops/common/tools/opslib/tasks.mjs +313 -0
  97. package/template/workflows/ops/common/tools/opslib/workspace.mjs +107 -0
  98. package/template/workflows/ops/common/tools/opslib/workspace_cli.mjs +80 -0
  99. package/template/workflows/ops/common/tools/validate-ops.mjs +11 -8
  100. package/template/workflows/ops/manifest.json +43 -7
  101. package/template/workflows/specdev/common/rules/path-reference-contract.md +6 -0
  102. package/template/workflows/specdev/common/skills/code-review/SKILL.md +3 -1
  103. package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +1 -0
  104. package/template/workflows/specdev/common/skills/plan-quality-review/SKILL.md +3 -1
  105. package/template/workflows/specdev/common/skills/research/SKILL.md +3 -1
  106. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +1 -0
@@ -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
+ }
@@ -0,0 +1,107 @@
1
+ /** Explicit controller/server/project routing and bounded workspace paths. */
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative as pathRelative, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { digest, exact, identifier, noSymlinks, OpsError, readJson } from "./core.mjs";
6
+
7
+ export const WORKS = Object.freeze({
8
+ controller: "I-initialize", access: "S-server-connect", server: "H-host-manage",
9
+ project: "D-project-deploy", inventory: "V-inventory-view",
10
+ });
11
+ const WORKFLOW = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
12
+ export const MAX_JSON_BYTES = 16 * 1024 * 1024;
13
+
14
+ export function statePath(value) {
15
+ if (typeof value !== "string" || !isAbsolute(value) || value.includes("\0") || value.split(/[\\/]/).includes("..")) {
16
+ throw new OpsError("--state must be an explicit absolute path without traversal; never inferred from cwd");
17
+ }
18
+ const state = resolve(value);
19
+ const rel = pathRelative(WORKFLOW, state);
20
+ if (dirname(state) === state || rel === "" || (rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel))) {
21
+ throw new OpsError("state must be a dedicated runtime directory outside static workflow assets");
22
+ }
23
+ noSymlinks(state, { allowMissing: false });
24
+ if (!statSync(state).isDirectory()) throw new OpsError("state is not a directory");
25
+ return state;
26
+ }
27
+
28
+ export function readBoundedJson(path) {
29
+ noSymlinks(path, { allowMissing: false });
30
+ const st = statSync(path);
31
+ if (!st.isFile() || st.size > MAX_JSON_BYTES) throw new OpsError("JSON file is not regular or exceeds 16 MiB");
32
+ return readJson(path);
33
+ }
34
+
35
+ export function readRegistry(state) {
36
+ statePath(state);
37
+ const value = readBoundedJson(join(state, "status.json"));
38
+ if (value.schema_version !== 3 || value.workflow !== "ops" || !Number.isSafeInteger(value.revision) || value.revision < 0) {
39
+ throw new OpsError("workspace requires OPS status schema v3; legacy state must be imported, not overwritten");
40
+ }
41
+ if (!value.controller || !value.controller.controller_id) throw new OpsError("initialize the controller first");
42
+ identifier(value.controller.controller_id, "controller_id");
43
+ if (typeof value.controller.state_root !== "string" || resolve(value.controller.state_root) !== resolve(state)) throw new OpsError("controller state_root mismatch; do not silently adopt another workspace");
44
+ for (const key of ["hosts", "projects", "deployments", "bindings", "allocations", "releases"]) {
45
+ if (!value[key] || typeof value[key] !== "object" || Array.isArray(value[key])) throw new OpsError(`invalid registry map: ${key}`);
46
+ if (Object.keys(value[key]).length > 10000) throw new OpsError(`registry map exceeds workspace bound: ${key}`);
47
+ for (const id of Object.keys(value[key])) identifier(id, key);
48
+ }
49
+ for (const [id, h] of Object.entries(value.hosts)) {
50
+ if (!h || h.host_id !== id || !["local", "ssh"].includes(h.transport)) throw new OpsError(`invalid host record: ${id}`);
51
+ }
52
+ for (const [id, p] of Object.entries(value.projects)) {
53
+ if (!p || p.project_id !== id) throw new OpsError(`invalid project record: ${id}`);
54
+ }
55
+ for (const [id, d] of Object.entries(value.deployments)) {
56
+ if (!d || d.deployment_id !== id || !Object.hasOwn(value.hosts, d.host_id) || !Object.hasOwn(value.projects, d.project_id)) {
57
+ throw new OpsError(`orphan or invalid deployment: ${id}`);
58
+ }
59
+ }
60
+ return value;
61
+ }
62
+
63
+ export function ids(value, label) {
64
+ if (!Array.isArray(value) || new Set(value).size !== value.length) throw new OpsError(`${label} must be an array of distinct IDs`);
65
+ return value.map((id) => identifier(id, label));
66
+ }
67
+
68
+ export function route(registry, context) {
69
+ exact(context, new Set(["scope", "server_ids", "project_ids"]), new Set(["scope", "server_ids", "project_ids"]), "context");
70
+ if (!Object.hasOwn(WORKS, context.scope)) throw new OpsError("scope must be controller, access, server, project or inventory");
71
+ const servers = ids(context.server_ids, "server_ids");
72
+ const projects = ids(context.project_ids, "project_ids");
73
+ if (context.scope === "controller" && (servers.length || projects.length)) throw new OpsError("controller scope cannot target a server or project; use S/H/D");
74
+ if (["access", "server"].includes(context.scope) && (servers.length !== 1 || projects.length)) throw new OpsError("S/H require exactly one explicit server and no project");
75
+ if (context.scope === "project" && (!servers.length || projects.length !== 1)) throw new OpsError("D requires explicit server(s) and exactly one project; no last-used/cwd fallback");
76
+ for (const id of servers) if (context.scope !== "access" && !Object.hasOwn(registry.hosts, id)) throw new OpsError(`unknown server: ${id}; enroll via S first`);
77
+ for (const id of projects) if (!Object.hasOwn(registry.projects, id)) throw new OpsError(`unknown project: ${id}; explicitly register the project first`);
78
+ return { scope: context.scope, work: WORKS[context.scope], controller_id: registry.controller.controller_id,
79
+ server_ids: servers, project_ids: projects, host_ids: servers, registration_required: servers.filter((id) => !Object.hasOwn(registry.hosts, id)), note: "server maps to Host.host_id; controller is never an implicit local server" };
80
+ }
81
+
82
+ export function catalogUnlocked(state) {
83
+ noSymlinks(join(state, ".locks", "catalog"));
84
+ if (existsSync(join(state, ".locks", "catalog"))) throw new OpsError("catalog lock held; inspect the owner before generating a new snapshot");
85
+ }
86
+
87
+ export function fileDigest(path) {
88
+ noSymlinks(path, { allowMissing: false });
89
+ if (!statSync(path).isFile() || statSync(path).size > MAX_JSON_BYTES) throw new OpsError("file exceeds snapshot bound");
90
+ return digest(readFileSync(path));
91
+ }
92
+
93
+ /** Pin identity and transport, not changeable host_services annotations. */
94
+ export function targetDigest(host) {
95
+ if (!host) throw new OpsError("missing target host");
96
+ const { host_id, root, identity, platform, transport, connection } = host;
97
+ const known_hosts_digest = transport === "ssh" ? fileDigest(connection.known_hosts) : null;
98
+ return digest({ host_id, root, identity, platform, transport, connection, known_hosts_digest });
99
+ }
100
+
101
+ export function containedPath(root, path) {
102
+ if (typeof path !== "string" || !isAbsolute(path) || path.split(/[\\/]/).includes("..")) throw new OpsError("expected an absolute contained artifact path");
103
+ const rel = pathRelative(resolve(root), resolve(path));
104
+ if (!rel || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) throw new OpsError("artifact path outside workspace");
105
+ noSymlinks(path, { allowMissing: false });
106
+ return resolve(path);
107
+ }
@@ -0,0 +1,80 @@
1
+ /** Additive workspace commands. Legacy CLI semantics remain owned by cli.mjs. */
2
+ import { isAbsolute } from "node:path";
3
+ import { OpsError } from "./core.mjs";
4
+ import { readBoundedJson, readRegistry, route, statePath } from "./workspace.mjs";
5
+ import { checkServer } from "./server_checks.mjs";
6
+ import { generateFleet } from "./fleet.mjs";
7
+ import { authorizeTask, prepareTask, runTask } from "./tasks.mjs";
8
+
9
+ export const COMMANDS = Object.freeze(["workspace-help", "route", "server-check", "fleet", "task-plan", "task-authorize", "task-run"]);
10
+ const options = {
11
+ route: ["scope", "server", "project"],
12
+ "server-check": ["server", "profile", "min-free-mib", "min-memory-mib"],
13
+ fleet: ["stale-hours"], "task-plan": ["file"],
14
+ "task-authorize": ["task", "digest", "by", "statement", "ack-destructive"], "task-run": ["task"], "workspace-help": [],
15
+ };
16
+ export function workspaceCommand(argv) {
17
+ const rest = [...argv];
18
+ for (let i = rest.length - 1; i >= 0; i--) if (rest[i] === "--state") rest.splice(i, 2);
19
+ return COMMANDS.includes(rest[0]) ? rest[0] : null;
20
+ }
21
+ export function parseWorkspaceArgs(argv) {
22
+ const command = workspaceCommand(argv);
23
+ if (!command) throw new OpsError("unknown workspace command");
24
+ const args = [...argv], out = { command };
25
+ args.splice(args.indexOf(command), 1);
26
+ const allowed = new Set(["state", ...options[command]]);
27
+ while (args.length) {
28
+ const flag = args.shift();
29
+ if (!flag.startsWith("--") || !allowed.has(flag.slice(2))) throw new OpsError(`unknown ${command} argument: ${flag}`);
30
+ const key = flag.slice(2);
31
+ if (Object.hasOwn(out, key)) throw new OpsError(`duplicate argument: ${flag}`);
32
+ const value = args.shift();
33
+ if (!value || value.startsWith("--")) throw new OpsError(`${flag} needs a value`);
34
+ out[key] = value;
35
+ }
36
+ if (command !== "workspace-help" && !out.state) throw new OpsError("--state is required and must be absolute");
37
+ return out;
38
+ }
39
+ const required = (args, key) => { if (!args[key]) throw new OpsError(`--${key} is required`); return args[key]; };
40
+ const list = (v) => v ? v.split(",") : [];
41
+ function integer(value, fallback) {
42
+ if (value === undefined) return fallback;
43
+ if (!/^[1-9][0-9]*$/.test(value) || !Number.isSafeInteger(Number(value))) throw new OpsError("threshold must be a positive integer");
44
+ return Number(value);
45
+ }
46
+ export async function main(argv = process.argv.slice(2)) {
47
+ try {
48
+ const args = parseWorkspaceArgs(argv);
49
+ if (args.command === "workspace-help") {
50
+ process.stdout.write(JSON.stringify({ commands: options, usage: "node ops.mjs --state ABSOLUTE_STATE COMMAND [options]",
51
+ workflow: "I controller; S server access; H server baseline; D project deployment; V local inventory",
52
+ task: "task-plan -> record existing explicit user authorization once via task-authorize -> task-run; no per-step reconfirmation; no automatic retry",
53
+ legacy: "All other commands retain the original plan/approve/apply contracts" }, null, 2) + "\n");
54
+ return 0;
55
+ }
56
+ const state = statePath(args.state);
57
+ // Keep the existing complete schema/semantic validator authoritative in the installed runtime.
58
+ const { load } = await import("./model.mjs");
59
+ load(state);
60
+ let result;
61
+ switch (args.command) {
62
+ case "route": result = route(readRegistry(state), { scope: required(args, "scope"), server_ids: list(args.server), project_ids: list(args.project) }); break;
63
+ case "server-check": result = await checkServer(state, required(args, "server"), { profile: args.profile ?? "base",
64
+ minFreeMiB: integer(args["min-free-mib"], 1024), minMemoryMiB: integer(args["min-memory-mib"], 256) }); break;
65
+ case "fleet": result = generateFleet(state, { staleHours: integer(args["stale-hours"], 24) }); break;
66
+ case "task-plan": {
67
+ const file = required(args, "file");
68
+ if (!isAbsolute(file)) throw new OpsError("--file must be an absolute task request path");
69
+ result = await prepareTask(state, readBoundedJson(file)); break;
70
+ }
71
+ case "task-authorize": result = authorizeTask(state, required(args, "task"), required(args, "digest"), required(args, "by"), required(args, "statement"), args["ack-destructive"] ?? null); break;
72
+ case "task-run": result = await runTask(state, required(args, "task")); break;
73
+ }
74
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
75
+ return ["blocked", "failed", "partial", "unknown", "stale", "docs_pending", "interrupted"].includes(result.status) ? 2 : 0;
76
+ } catch (error) {
77
+ process.stderr.write(JSON.stringify({ status: "blocked", error: error.message || String(error) }) + "\n");
78
+ return 2;
79
+ }
80
+ }
@@ -5,10 +5,10 @@ import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { validateStatus } from "./opslib/model.mjs";
8
+ import { WORKS } from "./opslib/workspace.mjs";
8
9
 
9
10
  const tools = dirname(fileURLToPath(import.meta.url));
10
11
  const root = resolve(tools, "../..");
11
- const expected = ["D-project-deploy", "H-host-manage", "I-initialize"];
12
12
  const args = process.argv.slice(2);
13
13
 
14
14
  try {
@@ -16,12 +16,17 @@ try {
16
16
  const works = readdirSync(root, { withFileTypes: true })
17
17
  .filter((x) => x.isDirectory() && !["common", "_state", "__pycache__"].includes(x.name))
18
18
  .map((x) => x.name).sort();
19
- if (JSON.stringify(works) !== JSON.stringify(expected)) throw new Error("OPS must expose exactly three work directories");
19
+ const expected = Object.values(WORKS).sort();
20
+ if (JSON.stringify(works) !== JSON.stringify(expected)) throw new Error("OPS Work directories differ from explicit scope router");
21
+ const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8"));
22
+ const stages = manifest.stages.map((s) => s.id).sort();
23
+ if (new Set(stages).size !== stages.length || JSON.stringify(stages) !== JSON.stringify(works.map((w) => w.slice(2)).sort())) throw new Error("manifest stages differ from Work entries");
24
+ for (const stage of manifest.stages) for (const id of stage.after) if (!stages.includes(id)) throw new Error("unknown manifest prerequisite " + id);
20
25
  for (const id of works) {
21
26
  const text = readFileSync(join(root, id, id + ".md"), "utf8");
22
- if (!text.includes("type: workflow-entry") || !text.includes("读取范围")) throw new Error("invalid work " + id);
27
+ if (!text.includes("type: workflow-entry") || !text.includes("读取范围") || !text.includes("id: ops/" + id.slice(2))) throw new Error("invalid work " + id);
23
28
  }
24
- for (const file of ["INDEX.md", "README.md", "manifest.json", "runtime-contract.json", "common/USAGE.md", "common/CAPABILITIES.md", "common/tools/opslib/execution.mjs"]) {
29
+ for (const file of ["INDEX.md", "README.md", "manifest.json", "runtime-contract.json", "common/USAGE.md", "common/WORKSPACE-USAGE.md", "common/CAPABILITIES.md", "common/templates/FLEET.html", "common/schemas/fleet-view.schema.json", "common/schemas/task-request.schema.json", "common/schemas/server-check.schema.json", "common/tools/opslib/execution.mjs", "common/tools/opslib/tasks.mjs", "common/tools/opslib/fleet.mjs"]) {
25
30
  if (!existsSync(join(root, file))) throw new Error("missing " + file);
26
31
  }
27
32
  for (const name of readdirSync(join(root, "common/schemas")).filter((x) => x.endsWith(".json"))) {
@@ -30,10 +35,8 @@ try {
30
35
  JSON.parse(readFileSync(join(root, "common/toolchains/volta-linux.json"), "utf8"));
31
36
  validateStatus(JSON.parse(readFileSync(join(root, "_state/status.json"), "utf8")));
32
37
  console.log("resource schema valid");
33
- console.log(JSON.stringify({
34
- valid: true, version: "2.2.0", workers: works, schema_version: 3,
35
- scope: "static and empty-resource-schema check; not live target acceptance",
36
- }, null, 2));
38
+ console.log(JSON.stringify({ valid: true, version: manifest.version, workers: works, schema_version: 3,
39
+ scope: "static and empty-resource-schema check; not live target acceptance" }, null, 2));
37
40
  } else if (args.length === 2 && ["--state-root", "--state"].includes(args[0])) {
38
41
  const p = spawnSync(process.execPath, [join(tools, "ops.mjs"), "--state", resolve(args[1]), "validate"], { encoding: "utf8", timeout: 30000 });
39
42
  if (p.status !== 0) throw new Error(p.stdout + p.stderr);
@@ -22,31 +22,50 @@
22
22
  "risk": "local-reversible",
23
23
  "context_budget": 16000
24
24
  },
25
+ {
26
+ "id": "server-connect",
27
+ "after": [
28
+ "initialize"
29
+ ],
30
+ "inputs": [
31
+ "server",
32
+ "verified-host-key",
33
+ "key-or-agent"
34
+ ],
35
+ "outputs": [
36
+ "server-registration",
37
+ "inventory",
38
+ "server-checks"
39
+ ],
40
+ "risk": "production-critical",
41
+ "context_budget": 20000
42
+ },
25
43
  {
26
44
  "id": "host-manage",
27
45
  "after": [],
28
46
  "inputs": [
29
- "host",
47
+ "server",
30
48
  "spec",
31
- "approval"
49
+ "task-authorization-or-plan-approval"
32
50
  ],
33
51
  "outputs": [
34
52
  "host-run",
35
53
  "verification",
36
- "dual-documents"
54
+ "dual-documents",
55
+ "server-checks"
37
56
  ],
38
57
  "risk": "production-critical",
39
- "context_budget": 22000
58
+ "context_budget": 24000
40
59
  },
41
60
  {
42
61
  "id": "project-deploy",
43
62
  "after": [],
44
63
  "inputs": [
45
64
  "project",
46
- "hosts",
65
+ "explicit-servers",
47
66
  "bindings",
48
67
  "spec",
49
- "approval"
68
+ "task-authorization-or-plan-approval"
50
69
  ],
51
70
  "outputs": [
52
71
  "release",
@@ -54,7 +73,24 @@
54
73
  "dual-documents"
55
74
  ],
56
75
  "risk": "production-critical",
57
- "context_budget": 28000
76
+ "context_budget": 30000
77
+ },
78
+ {
79
+ "id": "inventory-view",
80
+ "after": [],
81
+ "inputs": [
82
+ "resource-state",
83
+ "server-checks",
84
+ "task-summaries"
85
+ ],
86
+ "outputs": [
87
+ "fleet-markdown",
88
+ "fleet-json",
89
+ "fleet-html",
90
+ "view-receipt"
91
+ ],
92
+ "risk": "local-reversible",
93
+ "context_budget": 16000
58
94
  }
59
95
  ]
60
96
  }