@namewta/speculo 1.0.6 → 1.0.7

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 (51) hide show
  1. package/dist/src/ops-resources.js +1 -1
  2. package/dist/src/ops-resources.js.map +1 -1
  3. package/package.json +1 -1
  4. package/template/workflows/ops/D-project-deploy/D-project-deploy.md +1 -1
  5. package/template/workflows/ops/H-host-manage/H-host-manage.md +1 -1
  6. package/template/workflows/ops/I-initialize/I-initialize.md +2 -2
  7. package/template/workflows/ops/README.md +2 -2
  8. package/template/workflows/ops/common/CAPABILITIES.md +2 -2
  9. package/template/workflows/ops/common/USAGE.md +24 -24
  10. package/template/workflows/ops/common/examples/README.md +1 -1
  11. package/template/workflows/ops/common/examples/register.example.json +1 -1
  12. package/template/workflows/ops/common/schemas/host.schema.json +1 -1
  13. package/template/workflows/ops/common/schemas/plan.schema.json +3 -3
  14. package/template/workflows/ops/common/schemas/spec.schema.json +1 -1
  15. package/template/workflows/ops/common/schemas/status.schema.json +1 -1
  16. package/template/workflows/ops/common/tests/test_ops.mjs +752 -0
  17. package/template/workflows/ops/common/tools/bootstrap.ps1 +2 -2
  18. package/template/workflows/ops/common/tools/bootstrap.sh +2 -2
  19. package/template/workflows/ops/common/tools/demo-local.mjs +101 -0
  20. package/template/workflows/ops/common/tools/ops.mjs +4 -0
  21. package/template/workflows/ops/common/tools/opslib/agent.mjs +873 -0
  22. package/template/workflows/ops/common/tools/opslib/cli.mjs +311 -0
  23. package/template/workflows/ops/common/tools/opslib/core.mjs +393 -0
  24. package/template/workflows/ops/common/tools/opslib/docs.mjs +252 -0
  25. package/template/workflows/ops/common/tools/opslib/execution.mjs +378 -0
  26. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +109 -0
  27. package/template/workflows/ops/common/tools/opslib/model.mjs +272 -0
  28. package/template/workflows/ops/common/tools/opslib/{native_windows.py → native_windows.mjs} +16 -12
  29. package/template/workflows/ops/common/tools/opslib/planner.mjs +687 -0
  30. package/template/workflows/ops/common/tools/opslib/services.mjs +76 -0
  31. package/template/workflows/ops/common/tools/opslib/sources.mjs +56 -0
  32. package/template/workflows/ops/common/tools/opslib/transport.mjs +127 -0
  33. package/template/workflows/ops/common/tools/validate-ops.mjs +43 -30
  34. package/template/workflows/ops/common/tests/test_ops.py +0 -392
  35. package/template/workflows/ops/common/tools/demo-local.py +0 -64
  36. package/template/workflows/ops/common/tools/ops.py +0 -7
  37. package/template/workflows/ops/common/tools/opslib/__init__.py +0 -2
  38. package/template/workflows/ops/common/tools/opslib/__pycache__/__init__.cpython-312.pyc +0 -0
  39. package/template/workflows/ops/common/tools/opslib/__pycache__/core.cpython-312.pyc +0 -0
  40. package/template/workflows/ops/common/tools/opslib/__pycache__/model.cpython-312.pyc +0 -0
  41. package/template/workflows/ops/common/tools/opslib/agent.py +0 -510
  42. package/template/workflows/ops/common/tools/opslib/cli.py +0 -172
  43. package/template/workflows/ops/common/tools/opslib/core.py +0 -199
  44. package/template/workflows/ops/common/tools/opslib/docs.py +0 -199
  45. package/template/workflows/ops/common/tools/opslib/execution.py +0 -248
  46. package/template/workflows/ops/common/tools/opslib/host_recipes.py +0 -67
  47. package/template/workflows/ops/common/tools/opslib/model.py +0 -199
  48. package/template/workflows/ops/common/tools/opslib/planner.py +0 -497
  49. package/template/workflows/ops/common/tools/opslib/services.py +0 -47
  50. package/template/workflows/ops/common/tools/opslib/sources.py +0 -27
  51. package/template/workflows/ops/common/tools/opslib/transport.py +0 -55
@@ -0,0 +1,378 @@
1
+ /** Digest-bound approval, target receipts, fail-closed resume and dual documentation delivery. */
2
+ import { closeSync, existsSync, fsyncSync, openSync, readdirSync, readFileSync, writeSync } from "node:fs";
3
+ import { dirname, join, relative as pathRelative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { posix, win32 } from "node:path";
6
+ import {
7
+ atomicWrite, canonical, digest, now, noSymlinks, OpsError, privateDir, readJson,
8
+ relative, resolveSecrets, secure, UnknownResult, withLock, writeJson,
9
+ } from "./core.mjs";
10
+ import { load, save, ledgerLoad, validate, validateStatus } from "./model.mjs";
11
+ import { locatePlan, envFile } from "./planner.mjs";
12
+ import { call as transportCall, hostTransportDigest } from "./transport.mjs";
13
+ import { deliveryBundle } from "./docs.mjs";
14
+
15
+ const COMMON = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
+
17
+ export function defaultEngineDigest() {
18
+ const opslib = join(COMMON, "tools", "opslib");
19
+ const schemas = join(COMMON, "schemas");
20
+ const files = [
21
+ ...readdirSync(opslib).filter((n) => n.endsWith(".mjs")).map((n) => join(opslib, n)),
22
+ ...readdirSync(schemas).filter((n) => n.endsWith(".json")).map((n) => join(schemas, n)),
23
+ ].sort();
24
+ const map = {};
25
+ for (const p of files) map[pathRelative(COMMON, p)] = digest(readFileSync(p));
26
+ return digest(map);
27
+ }
28
+
29
+ export const executionHooks = { call: transportCall, engineDigest: defaultEngineDigest };
30
+ export function engineDigest() { return executionHooks.engineDigest(); }
31
+ export function call(...args) { return executionHooks.call(...args); }
32
+
33
+ export function approval(state, run, expected, by, statement) {
34
+ const path = locatePlan(state, run);
35
+ const plan = readJson(path);
36
+ validate(plan, "plan");
37
+ if (digest(plan) !== expected) throw new OpsError("approval digest mismatch: reread the full plan report");
38
+ if (!by.trim() || statement.trim().length < 8) throw new OpsError("approval needs identified approver and a meaningful explicit confirmation statement");
39
+ if (Date.now() >= Date.parse(plan.expires_at.replace("Z", "+00:00"))) throw new OpsError("plan expired; replan");
40
+ const value = {
41
+ schema_version: 1, artifact: "ops-plan-approval", run_id: plan.run_id, plan_digest: expected,
42
+ approved_by: by, approved_at: now(), decision: "approved", statement,
43
+ };
44
+ validate(value, "approval");
45
+ return withLock(join(state, ".locks", "catalog"), { operation: "approve", run_id: plan.run_id }, () => {
46
+ if (digest(load(state)) !== plan.registry_digest) throw new OpsError("catalog drift since plan; replan before approval");
47
+ writeJson(join(dirname(path), "approval.json"), value, { exclusive: true });
48
+ return { run_id: plan.run_id, status: "approved", plan_digest: expected };
49
+ });
50
+ }
51
+
52
+ export function journal(folder, event) {
53
+ const path = join(folder, "journal.jsonl");
54
+ noSymlinks(path);
55
+ privateDir(dirname(path));
56
+ let previous = "0".repeat(64);
57
+ let seq = 1;
58
+ if (existsSync(path)) {
59
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean)) {
60
+ const old = JSON.parse(line);
61
+ seq = old.sequence + 1;
62
+ previous = old.event_digest;
63
+ }
64
+ }
65
+ const value = { sequence: seq, at: now(), previous_digest: previous, ...event };
66
+ value.event_digest = digest(value);
67
+ const fd = openSync(path, "a", 0o600);
68
+ try {
69
+ writeSync(fd, canonical(value));
70
+ writeSync(fd, "\n");
71
+ fsyncSync(fd);
72
+ } finally { closeSync(fd); }
73
+ secure(path);
74
+ }
75
+
76
+ export function verifyJournal(path) {
77
+ let previous = "0".repeat(64);
78
+ let expected = 1;
79
+ if (!existsSync(path)) return { events: 0 };
80
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/).filter((l) => l.length)) {
81
+ const v = JSON.parse(line);
82
+ const claimed = v.event_digest;
83
+ delete v.event_digest;
84
+ if (v.sequence !== expected || v.previous_digest !== previous || digest(v) !== claimed) throw new OpsError("journal integrity mismatch");
85
+ previous = claimed;
86
+ expected += 1;
87
+ }
88
+ return { events: expected - 1, last_digest: previous };
89
+ }
90
+
91
+ export function requestBase(plan, hid, ledger) {
92
+ const values = [];
93
+ for (const ref of Object.keys(plan.credential_versions)) {
94
+ const [cid, v] = ref.split("@");
95
+ values.push(...Object.values(ledger.entries[cid][v].values));
96
+ }
97
+ return {
98
+ host_id: hid, root: plan.hosts[hid].root, controller_id: plan.controller_id, run_id: plan.run_id,
99
+ plan_digest: digest(plan), adopt_root: (plan.allow_adopt_roots || []).includes(hid),
100
+ external_files: plan.external_files[hid] || [],
101
+ strict_docker_root: plan.registry_after.policies.strict_docker_root, secrets: values,
102
+ };
103
+ }
104
+
105
+ export function resolvedOp(operation, ledger) {
106
+ const op = resolveSecrets(structuredClone(operation), ledger);
107
+ if (op.kind === "write") {
108
+ if ("json_values" in operation) op.content = JSON.stringify(resolveSecrets(operation.json_values, ledger), null, 2) + "\n";
109
+ delete op.json_values;
110
+ if ("env_values" in operation) {
111
+ op.content = envFile(resolveSecrets(operation.env_values, ledger), { systemd: operation.env_format === "systemd" });
112
+ }
113
+ if ("content" in op) {
114
+ op.content_b64 = Buffer.from(op.content).toString("base64");
115
+ delete op.content;
116
+ }
117
+ delete op.env_values;
118
+ delete op.env_format;
119
+ }
120
+ return op;
121
+ }
122
+
123
+ export function commitObservations(state, current, plan, execution) {
124
+ const result = structuredClone(current);
125
+ const desired = plan.registry_after;
126
+ const records = execution.steps;
127
+ result.policies = desired.policies;
128
+ result.hosts = structuredClone(desired.hosts);
129
+ result.projects = structuredClone(desired.projects);
130
+ for (const did of plan.document_targets) {
131
+ let proposed = structuredClone(desired.deployments[did]);
132
+ const old = current.deployments[did];
133
+ const ops = plan.operations.filter((o) => o.deployment_id === did);
134
+ const statuses = ops.map((o) => records[o.step_id]?.status ?? "not-started");
135
+ const changing = ops.length > 0;
136
+ if (changing && statuses.every((x) => x === "succeeded")) {
137
+ proposed.status = proposed.status === "retired" ? "retired" : "docs_pending";
138
+ proposed.observed_version = proposed.version;
139
+ proposed.installed_at = proposed.installed_at || now();
140
+ proposed.updated_at = now();
141
+ } else if (changing) {
142
+ proposed.status = statuses.includes("unknown") ? "unknown" : (statuses.includes("failed") ? "failed" : "planned");
143
+ proposed.observed_version = old ? old.observed_version : null;
144
+ proposed.notes.push("本次发布未完成;配置/数据可能部分变更。计划版本不是已验证运行版本。不得盲目重跑。");
145
+ } else if (old) proposed = structuredClone(old);
146
+ else { proposed.status = "planned"; proposed.observed_version = null; }
147
+ result.deployments[did] = proposed;
148
+ }
149
+ for (const [aid, a] of Object.entries(desired.allocations)) {
150
+ if (aid in current.allocations && current.allocations[aid].status !== "planned") {
151
+ result.allocations[aid] = structuredClone(current.allocations[aid]);
152
+ continue;
153
+ }
154
+ const item = structuredClone(a);
155
+ const provisioning = plan.operations.filter((o) => o.allocation_id === aid);
156
+ item.status = provisioning.length && provisioning.every((o) => records[o.step_id]?.status === "succeeded") ? "active" : "planned";
157
+ result.allocations[aid] = item;
158
+ }
159
+ for (const [bid, b] of Object.entries(desired.bindings)) {
160
+ const item = structuredClone(b);
161
+ const consumer = result.deployments[b.consumer_deployment_id];
162
+ if (item.status === "active" && (!consumer || ["planned", "failed", "unknown"].includes(consumer.status))) item.status = "planned";
163
+ if (item.mode === "shared" && result.allocations[item.allocation_id].status !== "active" && item.status === "active") item.status = "planned";
164
+ result.bindings[bid] = item;
165
+ }
166
+ result.releases[plan.run_id] = {
167
+ run_id: plan.run_id, worker: plan.worker, status: execution.status,
168
+ plan_path: pathRelative(state, execution.plan_path),
169
+ host_ids: Object.keys(plan.hosts).sort(), deployment_ids: plan.document_targets, updated_at: now(),
170
+ results: Object.fromEntries(Object.entries(records).map(([sid, v]) => [sid, { status: v.status, at: v.at }])),
171
+ };
172
+ save(state, result);
173
+ execution.committed_registry_digest = digest(result);
174
+ return result;
175
+ }
176
+
177
+ function mirrorConfiguration(state, plan, execution, ledger) {
178
+ for (const original of plan.operations) {
179
+ if (original.kind !== "write" || !original.deployment_id || execution.steps[original.step_id]?.status !== "succeeded") continue;
180
+ const dep = plan.registry_after.deployments[original.deployment_id];
181
+ const h = plan.hosts[dep.host_id];
182
+ const adapter = h.platform === "windows" ? win32 : posix;
183
+ let rel;
184
+ try {
185
+ rel = adapter.relative(dep.root, original.path).replaceAll("\\", "/");
186
+ if (!rel || rel.startsWith("..") || adapter.isAbsolute(rel)) continue;
187
+ } catch { continue; }
188
+ if (!["env", "compose", "config", "service", "scripts"].includes(rel.split("/")[0])) continue;
189
+ const op = resolvedOp(original, ledger);
190
+ const dest = join(state, "hosts", dep.host_id, "deployments", dep.deployment_id, "server-files", relative(rel));
191
+ const bytes = Buffer.from(op.content_b64, "base64");
192
+ atomicWrite(dest, bytes);
193
+ if (digest(readFileSync(dest)) !== digest(bytes)) throw new OpsError("local configuration mirror readback mismatch");
194
+ }
195
+ }
196
+
197
+ function deliver(state, folder, plan, status, execution, ledger) {
198
+ const outbox = join(folder, "outbox.json");
199
+ const eligible = plan.document_targets.filter((did) => !["planned", "failed", "unknown"].includes(status.deployments[did].status));
200
+ if (!existsSync(outbox)) {
201
+ const documentStatus = structuredClone(status);
202
+ for (const did of eligible) {
203
+ if (documentStatus.deployments[did].status === "docs_pending") documentStatus.deployments[did].status = "completed";
204
+ }
205
+ writeJson(outbox, deliveryBundle(state, plan, documentStatus, ledger, eligible), { exclusive: true });
206
+ }
207
+ const bundle = readJson(outbox);
208
+ if (bundle.plan_digest !== digest(plan)) throw new OpsError("outbox does not belong to approved plan");
209
+ for (const item of bundle.local) {
210
+ const path = join(state, relative(item.path));
211
+ if (item.path === "knowledge/INDEX.md" && existsSync(path)) continue;
212
+ atomicWrite(path, item.content);
213
+ if (digest(readFileSync(path)) !== item.sha256) throw new OpsError("local document readback mismatch");
214
+ bundle.acks["local:" + item.path] = { sha256: item.sha256, verified_at: now() };
215
+ }
216
+ writeJson(outbox, bundle);
217
+ mirrorConfiguration(state, plan, execution, ledger);
218
+ for (const item of bundle.remote) {
219
+ const hid = item.host_id;
220
+ const base = requestBase(plan, hid, ledger);
221
+ const step = "docs-" + digest(Buffer.from(hid + ":" + item.path)).slice(0, 24);
222
+ const op = { step_id: step, kind: "write", path: item.path, content_b64: Buffer.from(item.content).toString("base64"), mode: 0o600, expected: item.expected };
223
+ const result = call(plan.hosts[hid], { ...base, action: "step", operation: op, operation_digest: digest(op) });
224
+ if (result.status !== "succeeded") throw new OpsError("target document write did not complete: " + item.path + " / " + result.status);
225
+ const observed = call(plan.hosts[hid], { action: "snapshot", paths: [item.path] }).paths[item.path];
226
+ if (observed.sha256 !== item.sha256) throw new OpsError("target document readback mismatch: " + item.path);
227
+ if (plan.hosts[hid].platform !== "windows" && (observed.mode & 0o077)) throw new OpsError("target plaintext documentation mode is too broad");
228
+ bundle.acks["remote:" + hid + ":" + item.path] = { sha256: item.sha256, verified_at: now() };
229
+ writeJson(outbox, bundle);
230
+ }
231
+ const receipt = { schema_version: 1, run_id: plan.run_id, plan_digest: digest(plan), completed_at: now(), documents: bundle.acks, status: "both-sides-verified" };
232
+ writeJson(join(folder, "docs-receipt.json"), receipt);
233
+ for (const did of eligible) {
234
+ const dep = status.deployments[did];
235
+ writeJson(join(state, "hosts", dep.host_id, "deployments", did, "docs-receipt.json"), receipt);
236
+ }
237
+ return receipt;
238
+ }
239
+
240
+ export function apply(state, run, { resume = false, docsOnly = false } = {}) {
241
+ const path = locatePlan(state, run);
242
+ const folder = dirname(path);
243
+ const plan = readJson(path);
244
+ validate(plan, "plan");
245
+ const approved = readJson(join(folder, "approval.json"));
246
+ validate(approved, "approval");
247
+ if (approved.plan_digest !== digest(plan) || approved.run_id !== plan.run_id) throw new OpsError("plan differs from approval");
248
+ if (plan.engine_digest !== engineDigest()) throw new OpsError("executor/schema code changed after planning; replan with this implementation");
249
+ const execPath = join(folder, "execution.json");
250
+ if (existsSync(execPath) && !(resume || docsOnly)) throw new OpsError("run already started; use resume or docs-sync, never a blind second apply");
251
+ if (!existsSync(execPath) && Date.now() >= Date.parse(plan.expires_at.replace("Z", "+00:00"))) {
252
+ throw new OpsError("approved plan expired before first execution");
253
+ }
254
+ return withLock(join(state, ".locks", "catalog"), { operation: "apply", run_id: plan.run_id }, () => {
255
+ let current = load(state);
256
+ const ledger = ledgerLoad(state);
257
+ const execution = existsSync(execPath)
258
+ ? readJson(execPath)
259
+ : { schema_version: 1, run_id: plan.run_id, plan_path: path, status: "executing", started_at: now(), steps: {}, errors: [], committed_registry_digest: null };
260
+ const expected = execution.committed_registry_digest || plan.registry_digest;
261
+ if (digest(current) !== expected) throw new OpsError("controller registry changed since this approved run; compile a new plan");
262
+ if (execution.status === "completed") return { run_id: plan.run_id, status: "completed", unchanged: true, report: join(folder, "RESULT.md") };
263
+ verifyJournal(join(folder, "journal.jsonl"));
264
+ for (const [hid, h] of Object.entries(plan.hosts)) {
265
+ if (hostTransportDigest(h) !== plan.transport_digests[hid]) throw new OpsError("SSH connection/known_hosts changed since approval");
266
+ }
267
+ for (const [ref, wanted] of Object.entries(plan.credential_versions)) {
268
+ const [cid, v] = ref.split("@");
269
+ if (digest(ledger.entries?.[cid]?.[v]) !== wanted) throw new OpsError("credential version changed/missing since approval: " + ref);
270
+ }
271
+ const locked = [];
272
+ let unknown = false;
273
+ let operationFailed = false;
274
+ let docError = null;
275
+ let original = null;
276
+ writeJson(execPath, execution);
277
+ journal(folder, { kind: "attempt-start", run_id: plan.run_id, resume, docs_only: docsOnly });
278
+ try {
279
+ for (const [hid, h] of Object.entries(plan.hosts)) {
280
+ call(h, { ...requestBase(plan, hid, ledger), action: "lock" });
281
+ locked.push(hid);
282
+ }
283
+ if (!docsOnly) {
284
+ for (original of plan.operations) {
285
+ const sid = original.step_id;
286
+ const previous = execution.steps[sid];
287
+ if (previous && previous.status === "succeeded") continue;
288
+ if (previous && previous.status === "failed") throw new OpsError("a terminal failed action needs a new recovery plan; failed actions are not replayed");
289
+ const hid = original.host_id;
290
+ const op = resolvedOp(original, ledger);
291
+ journal(folder, { kind: "step-dispatch", step_id: sid, host_id: hid, operation_digest: digest(original) });
292
+ const result = call(plan.hosts[hid], { ...requestBase(plan, hid, ledger), action: "step", operation: op, operation_digest: digest(original) }, { timeout: Math.max(1800, (op.timeout ?? 300) + 120) });
293
+ execution.steps[sid] = result;
294
+ writeJson(execPath, execution);
295
+ journal(folder, { kind: "step-result", step_id: sid, status: result.status, receipt_digest: digest(result) });
296
+ if (result.status !== "succeeded") {
297
+ unknown = result.status === "unknown";
298
+ operationFailed = true;
299
+ break;
300
+ }
301
+ }
302
+ } else if (plan.operations.some((o) => execution.steps[o.step_id]?.status !== "succeeded")) {
303
+ throw new OpsError("docs-sync only retries documentation after all operation receipts succeeded");
304
+ }
305
+ const completeOps = plan.operations.every((o) => execution.steps[o.step_id]?.status === "succeeded");
306
+ execution.status = unknown ? "unknown" : (completeOps ? "docs_pending" : "partial");
307
+ if (!docsOnly || !execution.committed_registry_digest) {
308
+ current = commitObservations(state, current, plan, execution);
309
+ writeJson(execPath, execution);
310
+ }
311
+ if (completeOps) {
312
+ try {
313
+ deliver(state, folder, plan, current, execution, ledger);
314
+ for (const did of plan.document_targets) {
315
+ if (current.deployments[did].status === "docs_pending") current.deployments[did].status = "completed";
316
+ }
317
+ execution.status = "completed";
318
+ current.releases[plan.run_id].status = "completed";
319
+ current.releases[plan.run_id].updated_at = now();
320
+ save(state, current);
321
+ execution.committed_registry_digest = digest(current);
322
+ } catch (e) {
323
+ if (!(e instanceof OpsError || e.code)) throw e;
324
+ execution.status = "docs_pending";
325
+ docError = e.message || String(e);
326
+ execution.errors.push({ phase: "documentation", at: now(), error: String(e.message || e) });
327
+ }
328
+ } else {
329
+ for (const did of plan.document_targets) {
330
+ const dep = current.deployments[did];
331
+ writeJson(join(state, "hosts", dep.host_id, "deployments", did, "deployment.json"), dep);
332
+ }
333
+ }
334
+ } catch (e) {
335
+ if (e instanceof UnknownResult) {
336
+ unknown = true;
337
+ execution.status = "unknown";
338
+ execution.errors.push({ phase: "transport", error: e.message, at: now() });
339
+ if (!execution.committed_registry_digest) {
340
+ if (original && !(original.step_id in execution.steps)) {
341
+ execution.steps[original.step_id] = { status: "unknown", at: now(), reason: "transport interrupted" };
342
+ }
343
+ current = commitObservations(state, current, plan, execution);
344
+ }
345
+ } else if (e instanceof OpsError || e.code || e instanceof TypeError) {
346
+ execution.status = "failed";
347
+ execution.errors.push({ phase: "execution", error: e.message || String(e), at: now() });
348
+ if (!execution.committed_registry_digest) current = commitObservations(state, current, plan, execution);
349
+ } else throw e;
350
+ } finally {
351
+ if (!unknown) {
352
+ for (const hid of [...locked].reverse()) {
353
+ try { call(plan.hosts[hid], { ...requestBase(plan, hid, ledger), action: "unlock" }); }
354
+ catch (e) {
355
+ if (e instanceof OpsError || e instanceof UnknownResult) {
356
+ execution.errors.push({ phase: "unlock", host_id: hid, error: e.message, at: now() });
357
+ if (execution.status === "completed") execution.status = "unknown";
358
+ } else throw e;
359
+ }
360
+ }
361
+ }
362
+ execution.updated_at = now();
363
+ if (plan.run_id in current.releases && current.releases[plan.run_id].status !== execution.status) {
364
+ current.releases[plan.run_id].status = execution.status;
365
+ save(state, current);
366
+ execution.committed_registry_digest = digest(current);
367
+ }
368
+ writeJson(execPath, execution);
369
+ journal(folder, { kind: "attempt-end", status: execution.status, errors: execution.errors.length });
370
+ const resultMd = "# 执行结果 " + plan.run_id + "\n\n状态:" + execution.status + "\n\n"
371
+ + Object.entries(execution.steps).map(([sid, v]) => `- ${sid}: ${v.status}`).join("\n")
372
+ + "\n\n错误/未完成项:\n" + JSON.stringify(execution.errors, null, 2)
373
+ + "\n\n双边文档验证仅在 docs-receipt.json 存在且内容为 both-sides-verified 时完成。业务数据不自动镜像到部署机。\n";
374
+ atomicWrite(join(folder, "RESULT.md"), resultMd);
375
+ }
376
+ return { run_id: plan.run_id, status: execution.status, report: join(folder, "RESULT.md"), documentation_error: docError, errors: execution.errors };
377
+ });
378
+ }
@@ -0,0 +1,109 @@
1
+ /** Pinned environment-management recipes. No latest-version guessing or implicit profile edits. */
2
+ import { identifier, exact, newId, now, OpsError, targetJoin, within } from "./core.mjs";
3
+ import { load } from "./model.mjs";
4
+ import { call } from "./transport.mjs";
5
+
6
+ function shlexQuote(s) {
7
+ if (s === "") return "''";
8
+ if (/[^\w@%+=:,./-]/.test(s)) return "'" + s.replaceAll("'", "'\"'\"'") + "'";
9
+ return s;
10
+ }
11
+
12
+ function reEscape(s) {
13
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+ }
15
+
16
+ export function environmentSpec(state, hid, request) {
17
+ exact(request, new Set(["account", "python_versions", "uv_path", "node_version", "npm_version", "volta_path", "java_version", "original_java_candidate", "sdkman_init"]), new Set(["account"]), "environment request");
18
+ identifier(request.account, "toolchain account");
19
+ const status = load(state);
20
+ const host = status.hosts[hid];
21
+ const inventory = call(host, { action: "probe", disk_roots: [host.root] }, { timeout: 180 });
22
+ const base = targetJoin(host, "_host/toolchains/" + request.account);
23
+ const defaults = Object.fromEntries(Object.entries(inventory.tools).filter(([k, v]) => ["java", "python", "python3", "node", "npm"].includes(k) && v.status === "observed"));
24
+ const actions = [];
25
+ const mkdir = (rel) => actions.push({ host_id: hid, kind: "mkdir", reason: "统一工具链与缓存根", path: rel });
26
+ const command = (argv, env, writes, verify, reason) => {
27
+ actions.push({
28
+ host_id: hid, kind: "install-toolchain", reason, argv, cwd: host.root, env, writes,
29
+ timeout: 3600, expected_defaults: defaults, verification: [{ ...verify, env }],
30
+ });
31
+ };
32
+ const pinned = (value, label) => {
33
+ if (typeof value !== "string" || !/^[0-9][A-Za-z0-9.+_-]*$/.test(value)) {
34
+ throw new OpsError(label + " must be a concrete version/candidate, not latest or a range");
35
+ }
36
+ return value;
37
+ };
38
+ if (request.python_versions) {
39
+ const uv = request.uv_path || inventory.tools.uv?.path;
40
+ if (!uv) throw new OpsError("uv is missing: first stage a checksum-verified installer in a separate host/bootstrap plan");
41
+ const install = base + (host.platform === "windows" ? "\\uv-python" : "/uv-python");
42
+ const cache = targetJoin(host, "_host/cache/" + request.account + "/uv");
43
+ mkdir("_host/toolchains/" + request.account + "/uv-python");
44
+ mkdir("_host/cache/" + request.account + "/uv");
45
+ for (const version of request.python_versions) {
46
+ pinned(version, "Python");
47
+ command(
48
+ [uv, "python", "install", version],
49
+ { UV_PYTHON_INSTALL_DIR: install, UV_CACHE_DIR: cache },
50
+ [install, cache],
51
+ { type: "command", argv: [uv, "python", "find", version], stdout_pattern: reEscape(install) },
52
+ "Install explicitly pinned Python; never replace OS Python or change the old PATH/default.",
53
+ );
54
+ }
55
+ }
56
+ if (request.node_version) {
57
+ const v = pinned(request.node_version, "Node");
58
+ const volta = request.volta_path || inventory.tools.volta?.path;
59
+ if (!volta) throw new OpsError("Volta missing: install a reviewed pinned release before the managed environment recipe");
60
+ const home = base + (host.platform === "windows" ? "\\volta" : "/volta");
61
+ mkdir("_host/toolchains/" + request.account + "/volta");
62
+ let old = (inventory.tools.node?.version ?? "").trim();
63
+ if (old.startsWith("v")) old = old.slice(1);
64
+ if (old && !/^\d+\.\d+\.\d+$/.test(old)) throw new OpsError("cannot confidently identify original Node default");
65
+ command([volta, "fetch", "node@" + v], { VOLTA_HOME: home }, [home], { type: "command", argv: [volta, "list", "all"] }, "Populate the managed Volta store without changing the user's default Node.");
66
+ if (old) {
67
+ command([volta, "install", "node@" + old], { VOLTA_HOME: home }, [home], { type: "command", argv: [volta, "list", "all"] }, "Set the managed Volta default back to the exact observed Node version; no shell profile modification.");
68
+ }
69
+ if (request.npm_version) {
70
+ const npm = pinned(request.npm_version, "npm");
71
+ command([volta, "fetch", "npm@" + npm], { VOLTA_HOME: home }, [home], { type: "command", argv: [volta, "list", "all"] }, "Fetch explicit npm version without changing user default.");
72
+ const oldnpm = (inventory.tools.npm?.version ?? "").trim();
73
+ if (/^\d+\.\d+\.\d+$/.test(oldnpm)) {
74
+ command([volta, "install", "npm@" + oldnpm], { VOLTA_HOME: home }, [home], { type: "command", argv: [volta, "list", "all"] }, "Restore the observed npm default inside the managed Volta home.");
75
+ }
76
+ }
77
+ }
78
+ if (request.java_version) {
79
+ if (host.platform === "windows") throw new OpsError("SDKMAN is not a native Windows adapter; register WSL as a distinct Linux execution host or retain native JDK");
80
+ const version = pinned(request.java_version, "JDK candidate");
81
+ const old = pinned(request.original_java_candidate, "original SDKMAN JDK candidate");
82
+ const init = request.sdkman_init;
83
+ const sdkroot = base + "/sdkman";
84
+ if (!init || !within(init, sdkroot, host.platform)) {
85
+ throw new OpsError("SDKMAN initialization must be staged in the managed SDKMAN root; never silently migrate an existing ~/.sdkman");
86
+ }
87
+ const q = shlexQuote;
88
+ const script = "set -euo pipefail\nexport SDKMAN_DIR=" + q(sdkroot) + "\nsource " + q(init) + "\nsdkman_auto_answer=true\nsdkman_selfupdate_enable=false\nsdk current java | grep -F -- " + q(old) + "\nsdk install java " + q(version) + "\nsdk default java " + q(old) + "\nsdk use java " + q(old) + "\njava -version\n";
89
+ const rel = "_host/scripts/" + newId("sdkman") + ".sh";
90
+ actions.push({ host_id: hid, kind: "write-file", reason: "Version-pinned SDKMAN script with original-default restoration", path: rel, content: script, mode: 0o700 });
91
+ command(["bash", targetJoin(host, rel)], {}, [sdkroot], { type: "command", argv: ["java", "-version"] }, "Install approved JDK, then restore and verify original candidate. Old JDK is never deleted.");
92
+ }
93
+ if (!actions.length) throw new OpsError("no explicit toolchain versions requested");
94
+ const consumers = new Set(Object.values(status.deployments).filter((d) => d.host_id === hid && d.status !== "retired").map((d) => d.deployment_id));
95
+ for (const b of Object.values(status.bindings)) {
96
+ if (consumers.has(b.provider_deployment_id) && b.status === "active") consumers.add(b.consumer_deployment_id);
97
+ }
98
+ return {
99
+ schema_version: 1,
100
+ worker: "H",
101
+ operation: "prepare",
102
+ reason: "Managed toolchain preparation with observed-default preservation",
103
+ hosts: [hid],
104
+ host_actions: actions,
105
+ acknowledged_consumers: [...consumers].sort(),
106
+ rollback_note: "Do not remove old toolchains. A failed default check stops the run. The plan does not modify user shell profiles; adoption of the managed activation environment is a separate explicit change.",
107
+ risk: "external-mutation",
108
+ };
109
+ }