@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.
Files changed (88) hide show
  1. package/package.json +5 -2
  2. package/template/canonical/canonical-specdev-goal-plan.md +2 -2
  3. package/template/canonical/canonical-specdev-grill-with-docs.md +2 -2
  4. package/template/canonical/canonical-specdev-spec.md +11 -136
  5. package/template/canonical/canonical-specdev-tickets.md +2 -2
  6. package/template/canonical/canonical-specdev-wayfinder.md +2 -2
  7. package/template/commands/handoff.md +16 -0
  8. package/template/commands/retro.md +1 -1
  9. package/template/skills/github-npm-ops/references/issue-pr-triage.md +8 -0
  10. package/template/skills/speculo-retro/references/entry-procedure.md +19 -3
  11. package/template/skills/speculo-retro/references/issue-drafting-sop.md +3 -1
  12. package/template/workflows/learning/A-archive/A-archive.md +2 -5
  13. package/template/workflows/learning/A-assess-and-plan/A-assess-and-plan.md +2 -5
  14. package/template/workflows/learning/C-consolidate/C-consolidate.md +2 -5
  15. package/template/workflows/learning/G-goal/G-goal.md +5 -23
  16. package/template/workflows/learning/H-homework/H-homework.md +2 -5
  17. package/template/workflows/learning/I-init-setup/I-init-setup.md +4 -9
  18. package/template/workflows/learning/L-lesson/L-lesson.md +2 -5
  19. package/template/workflows/learning/Q-question/Q-question.md +16 -8
  20. package/template/workflows/learning/Q-question/inquiry-template.md +2 -0
  21. package/template/workflows/learning/Q-question/tools/validate-inquiry.mjs +112 -0
  22. package/template/workflows/learning/R-review/R-review.md +3 -6
  23. package/template/workflows/learning/README.md +2 -2
  24. package/template/workflows/learning/common/rules/knowledge-organization.md +1 -1
  25. package/template/workflows/learning/common/rules/mastery-policy.md +1 -1
  26. package/template/workflows/learning/common/skills/socratic-questioning/SKILL.md +1 -1
  27. package/template/workflows/ops/D-project-deploy/D-project-deploy.md +15 -9
  28. package/template/workflows/ops/H-host-manage/H-host-manage.md +14 -21
  29. package/template/workflows/ops/H-host-manage/references/host-profiles.md +21 -0
  30. package/template/workflows/ops/I-initialize/I-initialize.md +7 -7
  31. package/template/workflows/ops/INDEX.md +20 -16
  32. package/template/workflows/ops/README.md +29 -73
  33. package/template/workflows/ops/S-server-connect/S-server-connect.md +28 -0
  34. package/template/workflows/ops/V-inventory-view/V-inventory-view.md +30 -0
  35. package/template/workflows/ops/common/CAPABILITIES.md +22 -15
  36. package/template/workflows/ops/common/WORKSPACE-USAGE.md +78 -0
  37. package/template/workflows/ops/common/examples/task-host.example.json +64 -0
  38. package/template/workflows/ops/common/rules/activation-and-memory.md +5 -3
  39. package/template/workflows/ops/common/rules/deployment-layout.md +51 -0
  40. package/template/workflows/ops/common/rules/inventory-and-records.md +55 -0
  41. package/template/workflows/ops/common/rules/recovery.md +6 -0
  42. package/template/workflows/ops/common/rules/research-and-decisions.md +18 -0
  43. package/template/workflows/ops/common/rules/server-onboarding.md +27 -0
  44. package/template/workflows/ops/common/rules/workspace-and-authorization.md +39 -0
  45. package/template/workflows/ops/common/schemas/fleet-view.schema.json +487 -0
  46. package/template/workflows/ops/common/schemas/server-check.schema.json +95 -0
  47. package/template/workflows/ops/common/schemas/task-request.schema.json +138 -0
  48. package/template/workflows/ops/common/templates/FLEET.html +61 -0
  49. package/template/workflows/ops/common/tests/test_ops_workspace.mjs +246 -0
  50. package/template/workflows/ops/common/tools/ops.mjs +5 -3
  51. package/template/workflows/ops/common/tools/opslib/fleet.mjs +155 -0
  52. package/template/workflows/ops/common/tools/opslib/server_checks.mjs +149 -0
  53. package/template/workflows/ops/common/tools/opslib/tasks.mjs +313 -0
  54. package/template/workflows/ops/common/tools/opslib/workspace.mjs +107 -0
  55. package/template/workflows/ops/common/tools/opslib/workspace_cli.mjs +80 -0
  56. package/template/workflows/ops/common/tools/validate-ops.mjs +11 -8
  57. package/template/workflows/ops/manifest.json +43 -7
  58. package/template/workflows/person/M-mao-zedong-cognitive-os/M-mao-zedong-cognitive-os.md +13 -68
  59. package/template/workflows/person/S-steelman-deliberation/S-steelman-deliberation.md +11 -123
  60. package/template/workflows/person/S-steelman-deliberation/references/state-and-publication.md +86 -0
  61. package/template/workflows/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md +2 -17
  62. package/template/workflows/specdev/A-archive-and-consolidate/consolidation-interview.md +10 -1
  63. package/template/workflows/specdev/C-code-review/C-code-review.md +3 -14
  64. package/template/workflows/specdev/C-code-review/code-review-template.md +4 -0
  65. package/template/workflows/specdev/D-diagnose-bugs/D-diagnose-bugs.md +1 -23
  66. package/template/workflows/specdev/G-grill-with-docs/G-grill-with-docs.md +2 -2
  67. package/template/workflows/specdev/I-implement/I-implement.md +2 -2
  68. package/template/workflows/specdev/I-implement/evidence-template.md +8 -0
  69. package/template/workflows/specdev/I-implement/references/implementation-procedure.md +8 -42
  70. package/template/workflows/specdev/I-init-setup/I-init-setup.md +16 -117
  71. package/template/workflows/specdev/I-init-setup/references/first-setup.md +46 -0
  72. package/template/workflows/specdev/L-learn-change/L-learn-change.md +11 -89
  73. package/template/workflows/specdev/L-learn-change/references/lesson-format.md +47 -0
  74. package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +2 -2
  75. package/template/workflows/specdev/P-prototype/P-prototype.md +2 -16
  76. package/template/workflows/specdev/R-review-architecture/R-review-architecture.md +19 -57
  77. package/template/workflows/specdev/R-review-architecture/architecture-review-template.md +2 -1
  78. package/template/workflows/specdev/R-review-architecture/references/selected-design.md +23 -0
  79. package/template/workflows/specdev/R-review-architecture/review-rubric.md +2 -2
  80. package/template/workflows/specdev/README.md +4 -4
  81. package/template/workflows/specdev/S-spec/S-spec.md +11 -138
  82. package/template/workflows/specdev/T-tickets/T-tickets.md +2 -2
  83. package/template/workflows/specdev/T-triage/T-triage.md +21 -136
  84. package/template/workflows/specdev/T-triage/intake-protocol.md +19 -0
  85. package/template/workflows/specdev/W-wayfinder/W-wayfinder.md +2 -2
  86. package/template/workflows/specdev/common/rules/deployment-handoff.md +23 -0
  87. package/template/workflows/specdev/common/skills/code-review/SKILL.md +5 -4
  88. package/template/workflows/specdev/common/skills/code-review/references/result-reuse.md +15 -0
@@ -0,0 +1,138 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:speculo:ops:task-request:v1",
4
+ "type": "object",
5
+ "properties": {
6
+ "schema_version": {
7
+ "const": 1
8
+ },
9
+ "task_id": {
10
+ "type": "string",
11
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
12
+ "maxLength": 80
13
+ },
14
+ "title": {
15
+ "type": "string",
16
+ "minLength": 3,
17
+ "maxLength": 200
18
+ },
19
+ "plan": {
20
+ "type": "string",
21
+ "minLength": 40,
22
+ "maxLength": 32000
23
+ },
24
+ "valid_for_hours": {
25
+ "type": "integer",
26
+ "minimum": 1,
27
+ "maximum": 24
28
+ },
29
+ "scope": {
30
+ "type": "object",
31
+ "properties": {
32
+ "scope": {
33
+ "enum": [
34
+ "server",
35
+ "project"
36
+ ]
37
+ },
38
+ "server_ids": {
39
+ "type": "array",
40
+ "items": {
41
+ "type": "string",
42
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
43
+ "maxLength": 80
44
+ },
45
+ "uniqueItems": true,
46
+ "minItems": 1
47
+ },
48
+ "project_ids": {
49
+ "type": "array",
50
+ "items": {
51
+ "type": "string",
52
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
53
+ "maxLength": 80
54
+ },
55
+ "uniqueItems": true,
56
+ "maxItems": 1
57
+ },
58
+ "related_project_ids": {
59
+ "type": "array",
60
+ "items": {
61
+ "type": "string",
62
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
63
+ "maxLength": 80
64
+ },
65
+ "uniqueItems": true
66
+ },
67
+ "allow_policy_changes": {
68
+ "type": "boolean"
69
+ },
70
+ "allow_public_ingress": {
71
+ "type": "boolean"
72
+ },
73
+ "allow_destructive": {
74
+ "type": "boolean"
75
+ }
76
+ },
77
+ "required": [
78
+ "scope",
79
+ "server_ids",
80
+ "project_ids",
81
+ "related_project_ids",
82
+ "allow_policy_changes",
83
+ "allow_public_ingress",
84
+ "allow_destructive"
85
+ ],
86
+ "additionalProperties": false
87
+ },
88
+ "input_paths": {
89
+ "type": "array",
90
+ "items": {
91
+ "type": "string",
92
+ "minLength": 1
93
+ },
94
+ "uniqueItems": true
95
+ },
96
+ "steps": {
97
+ "type": "array",
98
+ "minItems": 1,
99
+ "maxItems": 20,
100
+ "items": {
101
+ "type": "object",
102
+ "properties": {
103
+ "id": {
104
+ "type": "string",
105
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
106
+ "maxLength": 80
107
+ },
108
+ "title": {
109
+ "type": "string",
110
+ "minLength": 1,
111
+ "maxLength": 200
112
+ },
113
+ "spec": {
114
+ "$ref": "urn:speculo:ops:spec:v1"
115
+ }
116
+ },
117
+ "required": [
118
+ "id",
119
+ "title",
120
+ "spec"
121
+ ],
122
+ "additionalProperties": false
123
+ }
124
+ }
125
+ },
126
+ "required": [
127
+ "schema_version",
128
+ "task_id",
129
+ "title",
130
+ "plan",
131
+ "valid_for_hours",
132
+ "scope",
133
+ "input_paths",
134
+ "steps"
135
+ ],
136
+ "additionalProperties": false,
137
+ "$comment": "Register the existing spec.schema.json $id with a standards-compliant validator. Runtime validates each frozen spec through the original spec gateway."
138
+ }
@@ -0,0 +1,61 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src '{{SCRIPT_CSP}}'; style-src '{{STYLE_CSP}}'; base-uri 'none'; form-action 'none'; connect-src 'none'; img-src 'none'">
7
+ <meta name="referrer" content="no-referrer">
8
+ <title>Speculo · OPS 资产清单</title>
9
+ <style>
10
+ :root{color-scheme:light;--ink:#182633;--muted:#596b78;--line:#d7e0e5;--paper:#f3f6f8;--accent:#126c62;--card:#fff}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.65 system-ui,-apple-system,"Segoe UI",sans-serif}header{background:#142d36;color:white;padding:34px max(24px,calc((100vw - 1360px)/2));border-bottom:5px solid #45b9a6}.eyebrow{letter-spacing:.15em;font-size:12px;text-transform:uppercase;color:#a2d8d0}h1{font-size:32px;line-height:1.25;margin:10px 0}h2{font-size:21px;margin:0 0 14px}p{margin:8px 0}header p{color:#cbdde3;max-width:1000px}main{max-width:1408px;margin:auto;padding:24px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:0 0 22px}.stat,section,.notice,.filters{background:var(--card);border:1px solid var(--line);border-radius:10px}.stat{padding:16px 20px}.stat strong{display:block;font-size:30px;line-height:1.25}.stat span{color:var(--muted);font-size:13px}.notice{padding:14px 18px;margin-bottom:20px;border-left:4px solid #c38835}.filters{padding:16px;display:flex;flex-wrap:wrap;gap:12px;align-items:end;position:sticky;top:0;z-index:1;box-shadow:0 4px 15px #142d3610}label{display:flex;flex-direction:column;font-size:12px;color:var(--muted);gap:4px}label.search{flex:1;min-width:220px}input,select,button{font:inherit;border:1px solid #b9cbd1;border-radius:6px;padding:9px 12px;background:#fff;color:var(--ink)}input:focus,select:focus,button:focus{outline:3px solid #79c8bb;outline-offset:2px}button{cursor:pointer}button.link{border:0;padding:0;color:var(--accent);background:transparent;text-align:left;text-decoration:underline;text-underline-offset:3px}section{padding:22px;margin:20px 0}.scroll{overflow:auto}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;background:#eef4f5;font-weight:650;color:#3e5764}td,th{padding:11px 12px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}tr:last-child td{border-bottom:0}td{max-width:380px;min-width:80px}.muted{color:var(--muted);font-size:12px}.badge{display:inline-block;padding:2px 9px;border-radius:20px;background:#e8eef2;font-size:12px;white-space:nowrap}.pass,.ready,.completed{background:#e0f2e8;color:#216844}.fail,.failed,.blocked{background:#fbe6e5;color:#993b37}.unknown,.stale,.docs_pending,.interrupted,.needs-inspection,.incomplete{background:#fff0d6;color:#865719}.details{font-size:12px}summary{cursor:pointer;color:var(--accent)}.detail-list{padding:6px 0}.check{display:flex;justify-content:space-between;gap:16px;margin:4px 0}.empty{padding:20px;color:var(--muted)}footer{overflow-wrap:anywhere;color:var(--muted);font-size:12px;padding:10px 0 30px}code{font-size:12px}noscript{display:block;padding:24px}@media(max-width:760px){.stats{grid-template-columns:repeat(2,1fr)}main{padding:14px}section{padding:16px}.filters{position:static}header{padding:25px 20px}h1{font-size:25px}td,th{padding:9px}}@media print{.filters{display:none}header{background:white;color:var(--ink);padding:10px}header p{color:var(--muted)}section,.stat{break-inside:avoid}main{padding:0}}
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <header><div class="eyebrow">Speculo / Operations workspace</div><h1>服务器与项目,一处查看</h1><p id="meta"></p><p>服务器 → 项目 · 项目 → 服务器 · 系统服务与业务部署分别记录</p></header>
15
+ <main>
16
+ <div class="stats" id="stats"></div>
17
+ <div class="notice"><strong>这是离线快照,不是实时监控。</strong> <span id="notice-text">“ready”仅说明指定 profile 的必需检查项通过;“completed”是记录中的执行结果。未测、过期、身份变化及未完成文档均不能视为生产验收通过。</span></div>
18
+ <div class="filters"><label class="search">搜索服务器、项目、部署、版本或路径<input id="search" type="search" placeholder="输入关键词" autocomplete="off"></label><label>服务器<select id="server"><option value="">全部服务器</option></select></label><label>项目<select id="project"><option value="">全部项目</option></select></label><label>部署记录状态<select id="status"><option value="">全部状态</option></select></label><button id="reset" type="button">重置筛选</button></div>
19
+ <section><h2>01 / 服务器清单</h2><p class="muted">点击项目名称反向查询部署位置。展开检查项可查看尚未验证的基础环境。</p><div class="scroll" id="servers"></div></section>
20
+ <section><h2>02 / 项目清单</h2><p class="muted">项目是逻辑资源;同一项目可以部署在多台服务器。退役实例保留历史记录。</p><div class="scroll" id="projects"></div></section>
21
+ <section><h2>03 / 部署实例</h2><div class="scroll" id="deployments"></div></section>
22
+ <section><h2>04 / 系统服务与依赖</h2><p class="muted">Nginx、WireGuard 等主机级服务不伪装成业务项目;共享依赖通过部署关系显示。</p><div class="scroll" id="services"></div><div class="scroll" id="bindings"></div></section>
23
+ <section><h2>05 / 任务与执行记录</h2><p class="muted">缺少已验证终态的任务需核对原始回执;看板不证明进程仍在运行,也不触发重试、回滚或部署。</p><div class="scroll" id="tasks"></div><details><summary>查看原始 Run / Release 索引</summary><div class="scroll" id="releases"></div></details></section>
24
+ <footer id="footer"></footer>
25
+ </main>
26
+ <noscript>此看板需要本地 JavaScript 才能筛选;同目录 FLEET.md 提供无需脚本的清单。</noscript>
27
+ <script type="application/json" id="fleet-data">{{FLEET_DATA}}</script>
28
+ <script>
29
+ "use strict";
30
+ const data=JSON.parse(document.getElementById("fleet-data").textContent);
31
+ const $=(id)=>document.getElementById(id);
32
+ const byDeployment=new Map(data.deployments.map(d=>[d.deployment_id,d]));
33
+ const labels={registration:"资源登记","ssh-publickey":"SSH 公钥认证","machine-identity":"服务器身份",linux:"Linux 系统","node-runtime":"Node 运行环境","deployment-root":"部署根存在","disk-space":"磁盘空间",memory:"可用内存","docker-daemon":"本机 Docker","compose-version":"Compose 版本","docker-data-root":"Docker 数据目录","least-privilege":"最小权限","time-sync":"时间同步",firewall:"防火墙",egress:"外部依赖连通性","backup-restore":"备份恢复"};
34
+ const node=(tag,value,cls)=>{const n=document.createElement(tag);if(value!=null)n.textContent=String(value);if(cls)n.className=cls;return n;};
35
+ const badge=(value)=>node("span",value||"unknown","badge "+String(value||"unknown").replace(/[^a-z_-]/g,""));
36
+ function choose(kind,id){$(kind).value=id;$(kind==="server"?"project":"server").value="";$("search").value="";$("status").value="";render();}
37
+ const link=(kind,id)=>{const n=node("button",id,"link");n.type="button";n.addEventListener("click",()=>choose(kind,id));return n;};
38
+ function links(kind,ids){const n=node("div");if(!ids.length)n.textContent="—";ids.forEach((id,i)=>{if(i)n.append(document.createTextNode(", "));n.append(link(kind,id));});return n;}
39
+ function table(target,headers,rows){const root=$(target);root.replaceChildren();if(!rows.length){root.append(node("p","没有匹配记录。","empty"));return;}const t=node("table"),head=node("thead"),hr=node("tr"),body=node("tbody");for(const h of headers)hr.append(node("th",h));head.append(hr);for(const row of rows){const tr=node("tr");for(const value of row){const td=node("td");if(value instanceof Node)td.append(value);else td.textContent=value==null||value===""?"—":String(value);tr.append(td);}body.append(tr);}t.append(head,body);root.append(t);}
40
+ function checks(s){const d=node("details",null,"details");d.append(node("summary",`${s.profile||"未检查"} · ${s.checked_at||"无时间记录"}`));const wrap=node("div",null,"detail-list");if(!s.checks.length)wrap.textContent="未检查:不能据此推断服务器可用。";for(const c of s.checks){const row=node("div",null,"check");row.append(node("span",(labels[c.id]||c.id)+(c.required?" *":"")),badge(c.status));wrap.append(row);}d.append(wrap);return d;}
41
+ for(const [id,values] of [["server",data.servers.map(s=>s.server_id)],["project",data.projects.map(p=>p.project_id)],["status",[...new Set(data.deployments.map(d=>d.status))].sort()]])for(const value of values){const o=node("option",value);o.value=value;$(id).append(o);}
42
+ $("meta").textContent=`控制端 ${data.controller_id} / 资源修订 ${data.source_revision} / 生成于 ${data.generated_at}`;
43
+ $("notice-text").textContent=data.notice;
44
+ for(const [label,value] of [["已登记服务器",data.servers.length],["项目",data.projects.length],["部署实例(含历史)",data.deployments.length],["需关注的服务器",data.servers.filter(s=>s.readiness!=="ready").length]]){const box=node("div",null,"stat");box.append(node("strong",value),node("span",label));$("stats").append(box);}
45
+ $("footer").textContent=`数据格式 v${data.schema_version} · 检查过期阈值 ${data.stale_hours} 小时 · 资源摘要 ${data.source_digest}。此文件可能包含内部拓扑,请勿直接公开发布。`;
46
+ function render(){const server=$("server").value,project=$("project").value,st=$("status").value,q=$("search").value.toLocaleLowerCase();const match=(v)=>!q||JSON.stringify(v).toLocaleLowerCase().includes(q);
47
+ const ds=data.deployments.filter(d=>(!server||d.server_id===server)&&(!project||d.project_id===project)&&(!st||d.status===st)&&match(d));
48
+ const ss=data.servers.filter(s=>(!server||s.server_id===server)&&(!project||s.deployment_ids.some(id=>byDeployment.get(id)?.project_id===project))&&(!st||ds.some(d=>d.server_id===s.server_id))&&(match(s)||ds.some(d=>d.server_id===s.server_id)));
49
+ const ps=data.projects.filter(p=>(!project||p.project_id===project)&&(!server||p.deployment_ids.some(id=>byDeployment.get(id)?.server_id===server))&&(!st||ds.some(d=>d.project_id===p.project_id))&&(match(p)||ds.some(d=>d.project_id===p.project_id)));
50
+ table("servers",["服务器 / 名称","地址 / 系统","检查结果","检查详情(* 必需项)","项目","部署根"],ss.map(s=>[links("server",[s.server_id]),`${s.name} · ${s.address}${s.port?":"+s.port:""} / ${s.platform}`,badge(s.readiness),checks(s),links("project",s.project_ids),s.root]));
51
+ table("projects",["项目","名称","类别 / 服务类型","所在服务器","部署数量"],ps.map(p=>[link("project",p.project_id),p.name,`${p.kind} / ${p.service_type||"—"}`,links("server",p.server_ids),p.deployment_ids.length]));
52
+ table("deployments",["部署实例","项目 / 服务器","环境 / 方法","记录状态","计划版本","验证版本","根目录 / 更新时间"],ds.map(d=>{const where=node("div");where.append(link("project",d.project_id),node("div"),link("server",d.server_id));return[d.deployment_id,where,`${d.environment} / ${d.instance} / ${d.method}`,badge(d.status),d.desired_version,d.observed_version,`${d.root} / ${d.updated_at||"未验证"}`];}));
53
+ table("services",["服务器","主机级服务","类型","systemd 单元","监听端口"],ss.flatMap(s=>s.host_services.map(v=>[link("server",s.server_id),v.service_id,v.kind,v.unit,v.port])));
54
+ const depIds=new Set(ds.map(d=>d.deployment_id));table("bindings",["依赖关系","消费者部署","提供者部署","模式 / 状态"],data.bindings.filter(b=>depIds.has(b.consumer_deployment_id)||depIds.has(b.provider_deployment_id)).map(b=>[b.binding_id,b.consumer_deployment_id,b.provider_deployment_id||"external(不推断服务器)",`${b.mode} / ${b.status}`]));
55
+ table("tasks",["任务","范围","服务器","项目","记录状态","Run"],data.tasks.filter(t=>(!server||t.server_ids.includes(server))&&(!project||t.project_ids.includes(project))&&match(t)).map(t=>[t.task_id,t.scope,links("server",t.server_ids),links("project",t.project_ids),badge(t.status),t.run_ids.join(", ")]));
56
+ table("releases",["Run","Work","服务器","部署实例","记录状态","更新时间"],data.releases.filter(r=>(!server||r.server_ids.includes(server))&&(!project||r.deployment_ids.some(id=>byDeployment.get(id)?.project_id===project))&&match(r)).map(r=>[r.run_id,r.worker,links("server",r.server_ids),r.deployment_ids.join(", "),badge(r.status),r.updated_at]));}
57
+ for(const id of ["search","server","project","status"])$(id).addEventListener("input",render);
58
+ $("reset").addEventListener("click",()=>{for(const id of ["search","server","project","status"])$(id).value="";render();});render();
59
+ </script>
60
+ </body>
61
+ </html>
@@ -0,0 +1,246 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createHash } from "node:crypto";
4
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, statSync, symlinkSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { spawnSync } from "node:child_process";
9
+ import { digest, emptyStatus, now, writeJson } from "../tools/opslib/core.mjs";
10
+ import { route, readRegistry, statePath, targetDigest } from "../tools/opslib/workspace.mjs";
11
+ import { assessServer, readiness, checkServer, latestCheck } from "../tools/opslib/server_checks.mjs";
12
+ import { assertSpecScope, prepareTask, authorizeTask, runTask } from "../tools/opslib/tasks.mjs";
13
+ import { projectFleet, renderHtml, renderMarkdown, generateFleet } from "../tools/opslib/fleet.mjs";
14
+ import { parseWorkspaceArgs, workspaceCommand } from "../tools/opslib/workspace_cli.mjs";
15
+
16
+ function fixture(t) {
17
+ const root = mkdtempSync(join(tmpdir(), "speculo-workspace-"));
18
+ t.after(() => rmSync(root, { recursive: true, force: true }));
19
+ const state = join(root, "state"); mkdirSync(state);
20
+ const s = emptyStatus(); s.controller = { controller_id: "controller-a", state_root: state, created_at: now() };
21
+ for (const id of ["node-a", "node-b"]) s.hosts[id] = { host_id: id, display_name: id, platform: "linux", transport: "local", root: join(root, id), identity: "a".repeat(64), connection: {} };
22
+ for (const id of ["app-a", "app-b"]) s.projects[id] = { project_id: id, display_name: id, kind: "app", service_type: null, source: { type: "local", location: join(root, "source"), revision: "v1" } };
23
+ writeJson(join(state, "status.json"), s);
24
+ const save = () => writeJson(join(state, "status.json"), s);
25
+ return { root, state, s, save };
26
+ }
27
+ function request(id = "task-one") {
28
+ return { schema_version: 1, task_id: id, title: "Prepare one selected server", plan: "Inspect the explicitly selected server, prepare only its declared directory, verify the result, retain the original evidence, and never retry an unknown operation.", valid_for_hours: 2,
29
+ scope: { scope: "server", server_ids: ["node-a"], project_ids: [], related_project_ids: [], allow_policy_changes: false, allow_public_ingress: false, allow_destructive: false }, input_paths: [],
30
+ steps: [{ id: "prepare-root", title: "Prepare explicit directory", spec: { schema_version: 1, worker: "H", operation: "prepare", reason: "unit fixture", rollback_note: "retain", hosts: ["node-a"], host_actions: [{ host_id: "node-a", kind: "mkdir", path: "_host/cache", reason: "Create only the temporary fixture cache directory" }] } }] };
31
+ }
32
+ function inventory(h) {
33
+ return { identity: h.identity, platform: "linux", tools: { node: { version: "v22.22.3" } },
34
+ snapshots: { [h.root]: { kind: "directory" } }, diagnostics: { disks: { [h.root]: { free: 4 * 1024 ** 3 } }, memory: { MemAvailable: 1024 ** 3 } },
35
+ docker_control: { status: "observed", endpoint: "unix:///var/run/docker.sock", data_root: join(h.root, "_runtime/docker"), compose_version: "2.30.0" } };
36
+ }
37
+ function adapterFor(f, options = {}) {
38
+ const calls = { compile: 0, approve: 0, apply: 0 };
39
+ let engine = "e".repeat(64), ledger = { entries: {} };
40
+ const adapter = { load: readRegistry, validate: (s, kind) => { assert.equal(kind, "spec"); assert.equal(s.schema_version, 1); }, engineDigest: () => engine, ledgerLoad: () => ledger,
41
+ compilePlan: (state, path) => {
42
+ calls.compile++;
43
+ const spec = JSON.parse(readFileSync(path, "utf8")), current = readRegistry(state), id = `run-${calls.compile}`;
44
+ const plan = { run_id: id, worker: spec.worker, operation: spec.operation, controller_id: current.controller.controller_id, engine_digest: engine,
45
+ hosts: { "node-a": current.hosts["node-a"] }, registry_scope: { hosts: ["node-a"], projects: [], deployments: [], allocations: [], bindings: [], policies: false, public_ingress: false },
46
+ registry_after: structuredClone(current), operations: [{ host_id: "node-a", kind: "mkdir", deployment_id: null }], document_targets: [], affected_consumers: [], source_digests: {} };
47
+ options.plan?.(plan);
48
+ const planPath = join(state, "hosts/node-a/runs", id, "plan.json"); writeJson(planPath, plan);
49
+ return { run_id: id, plan_path: planPath, plan_digest: digest(plan) };
50
+ },
51
+ approval: (_state, _run, _digest, by, statement) => { calls.approve++; assert.match(by, /^task:/); assert.match(statement, /Delegated exact-spec/); },
52
+ apply: (state, _run) => { calls.apply++; options.beforeApply?.(); if (options.throwApply) throw new Error("transport disconnected");
53
+ const s = readRegistry(state); s.revision++; writeJson(join(state, "status.json"), s); return { status: options.result ?? "completed" }; },
54
+ };
55
+ return { adapter, calls, changeEngine: () => { engine = "f".repeat(64); }, changeLedger: () => { ledger = { entries: { changed: true } }; } };
56
+ }
57
+ async function authorized(f, req, adapter) {
58
+ const p = await prepareTask(f.state, req, adapter);
59
+ authorizeTask(f.state, req.task_id, p.task_digest, "test-user", "Execute this exact task without per-step reconfirmation", req.scope.allow_destructive ? "I-APPROVE-THIS-EXACT-DESTRUCTIVE-TASK" : null);
60
+ return p;
61
+ }
62
+
63
+ test("explicit layer routing distinguishes controller, access, server, project and inventory", (t) => {
64
+ const f = fixture(t);
65
+ assert.equal(route(f.s, { scope: "controller", server_ids: [], project_ids: [] }).work, "I-initialize");
66
+ assert.equal(route(f.s, { scope: "server", server_ids: ["node-a"], project_ids: [] }).work, "H-host-manage");
67
+ assert.equal(route(f.s, { scope: "project", server_ids: ["node-b"], project_ids: ["app-a"] }).work, "D-project-deploy");
68
+ assert.equal(route(f.s, { scope: "inventory", server_ids: [], project_ids: [] }).work, "V-inventory-view");
69
+ assert.deepEqual(route(f.s, { scope: "access", server_ids: ["new-node"], project_ids: [] }).registration_required, ["new-node"]);
70
+ for (const c of [
71
+ { scope: "controller", server_ids: ["node-a"], project_ids: [] }, { scope: "server", server_ids: ["node-a"], project_ids: ["app-a"] },
72
+ { scope: "project", server_ids: [], project_ids: ["app-a"] }, { scope: "server", server_ids: ["missing"], project_ids: [] },
73
+ { scope: "project", server_ids: ["node-a", "node-a"], project_ids: ["app-a"] },
74
+ ]) assert.throws(() => route(f.s, c));
75
+ });
76
+ test("workspace rejects guessed roots, traversal, symlinks, duplicate JSON keys and orphan deployments", (t) => {
77
+ const f = fixture(t);
78
+ for (const path of [".", "/", f.state + "/../state"]) assert.throws(() => statePath(path));
79
+ const link = join(f.root, "linked"); symlinkSync(f.state, link, "dir"); assert.throws(() => statePath(link), /symlink/);
80
+ writeFileSync(join(f.state, "status.json"), '{"schema_version":3,"schema_version":3}'); assert.throws(() => readRegistry(f.state), /duplicate JSON/);
81
+ f.s.deployments.dangling = { deployment_id: "dangling", host_id: "missing", project_id: "app-a" }; f.save(); assert.throws(() => readRegistry(f.state), /orphan/);
82
+ });
83
+ test("workspace argument parser rejects duplicates, unknown flags and missing values", () => {
84
+ assert.equal(workspaceCommand(["--state", "/x", "fleet"]), "fleet"); assert.equal(workspaceCommand(["--state", "/x", "apply", "--run", "abc"]), null);
85
+ assert.deepEqual(parseWorkspaceArgs(["fleet", "--state", "/x", "--stale-hours", "8"]), { command: "fleet", state: "/x", "stale-hours": "8" });
86
+ for (const a of [["fleet"], ["fleet", "--state", "/x", "--state", "/y"], ["fleet", "--state"], ["fleet", "--state", "/x", "--project", "app-a"]]) assert.throws(() => parseWorkspaceArgs(a));
87
+ });
88
+ test("CLI help works without network, server dependencies or state", () => {
89
+ const path = resolve(dirname(fileURLToPath(import.meta.url)), "../tools/ops.mjs");
90
+ const r = spawnSync(process.execPath, [path, "workspace-help"], { encoding: "utf8" }); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /server-check/);
91
+ });
92
+ test("Linux checklist distinguishes missing, failed, profile-ready and stale evidence", (t) => {
93
+ const f = fixture(t), h = f.s.hosts["node-a"], inv = inventory(h), at = new Date("2026-01-01T00:00:00Z");
94
+ assert.equal(readiness(assessServer(h), at.toISOString(), { at }), "unknown");
95
+ assert.equal(readiness(assessServer(h, { profile: "compose", inventory: inv }), at.toISOString(), { at }), "ready");
96
+ assert.equal(readiness(assessServer(h, { profile: "compose", inventory: inv }), at.toISOString(), { at: new Date("2026-01-03T00:00:00Z") }), "stale");
97
+ assert.equal(readiness(assessServer(h, { inventory: inv }), "2027-01-01", { at }), "unknown");
98
+ for (const change of [i => { i.identity = "b".repeat(64); }, i => { i.tools.node.version = "v22.16.0"; }, i => { i.tools.node.version = "v25.0.0"; }, i => { i.docker_control.compose_version = "2.29.9"; }, i => { i.docker_control.endpoint = "tcp://other:2375"; }, i => { i.docker_control.data_root = "/var/lib/docker"; }, i => { i.diagnostics.memory.MemAvailable = 1; }]) {
99
+ const v = structuredClone(inv); change(v); assert.equal(readiness(assessServer(h, { profile: "compose", inventory: v }), at.toISOString(), { at }), "blocked");
100
+ }
101
+ });
102
+ test("server check uses pinned public-key-only SSH and records restricted evidence", async (t) => {
103
+ const f = fixture(t), h = f.s.hosts["node-a"], kh = join(f.root, "known_hosts"); writeFileSync(kh, "fixture verified key");
104
+ Object.assign(h, { transport: "ssh", connection: { hostname: "example.invalid", username: "deploy", known_hosts: kh } }); f.save();
105
+ let spawned = 0, called = 0;
106
+ const a = { sshArgv: () => ["ssh", "-o", "StrictHostKeyChecking=yes"], spawn: (_cmd, args) => { spawned++; assert.ok(args.includes("PasswordAuthentication=no")); assert.ok(args.includes("KbdInteractiveAuthentication=no")); return { status: 0 }; }, call: () => { called++; return inventory(h); } };
107
+ await assert.rejects(checkServer(f.state, "node-a", { profile: "bad" }, a)); assert.equal(spawned, 0);
108
+ const r = await checkServer(f.state, "node-a", { profile: "compose" }, a); assert.equal(r.status, "ready"); assert.equal(called, 1);
109
+ const check = latestCheck(f.state, h); assert.equal(check.host_digest, targetDigest(h)); assert.equal(check.checks.length, 16);
110
+ if (process.platform !== "win32") assert.equal(statSync(r.report).mode & 0o777, 0o600);
111
+ writeFileSync(kh, "different key"); assert.notEqual(check.host_digest, targetDigest(h));
112
+ });
113
+ test("failed SSH login never triggers Node probe and is not reported ready", async (t) => {
114
+ const f = fixture(t), h = f.s.hosts["node-a"], kh = join(f.root, "known_hosts"); writeFileSync(kh, "fixture"); Object.assign(h, { transport: "ssh", connection: { hostname: "invalid", username: "deploy", known_hosts: kh } }); f.save();
115
+ const r = await checkServer(f.state, "node-a", {}, { sshArgv: () => ["ssh"], spawn: () => ({ status: 255 }), call: () => { throw new Error("must not probe"); } });
116
+ assert.equal(r.status, "blocked"); assert.equal(r.checks.find(c => c.id === "machine-identity").status, "unknown");
117
+ });
118
+ test("task preparation does not execute; one authorization runs ordered steps without repeated confirmation", async (t) => {
119
+ const f = fixture(t), a = adapterFor(f), req = request(); req.steps.push({ ...structuredClone(req.steps[0]), id: "verify-root" });
120
+ const p = await prepareTask(f.state, req, a.adapter); assert.deepEqual(a.calls, { compile: 0, approve: 0, apply: 0 });
121
+ await assert.rejects(runTask(f.state, req.task_id, a.adapter), /missing path/);
122
+ assert.throws(() => authorizeTask(f.state, req.task_id, "0".repeat(64), "test-user", "explicit confirmation"), /digest mismatch/);
123
+ authorizeTask(f.state, req.task_id, p.task_digest, "test-user", "Execute the already confirmed exact task");
124
+ const r = await runTask(f.state, req.task_id, a.adapter); assert.equal(r.status, "completed"); assert.equal(r.steps.length, 2); assert.deepEqual(a.calls, { compile: 2, approve: 2, apply: 2 });
125
+ await assert.rejects(runTask(f.state, req.task_id, a.adapter), /already started/); assert.equal(a.calls.apply, 2);
126
+ });
127
+ test("task IDs, frozen specs and authorization are immutable", async (t) => {
128
+ const f = fixture(t), a = adapterFor(f), req = request(), p = await authorized(f, req, a.adapter);
129
+ await assert.rejects(prepareTask(f.state, req, a.adapter), /already exists/);
130
+ assert.throws(() => authorizeTask(f.state, req.task_id, p.task_digest, "test-user", "same explicit confirmation"), /immutable/);
131
+ writeJson(join(f.state, "records/tasks/task-one/specs/prepare-root.json"), { changed: true });
132
+ assert.equal((await runTask(f.state, req.task_id, a.adapter)).status, "blocked"); assert.equal(a.calls.compile, 0);
133
+ });
134
+ test("task rejects server/project confusion and indirect provider/consumer scope escalation before planning", (t) => {
135
+ const f = fixture(t), req = request();
136
+ assert.throws(() => assertSpecScope({ ...req.steps[0].spec, worker: "D", host_actions: [] }, req.scope, f.s), /cannot silently/);
137
+ assert.throws(() => assertSpecScope({ ...req.steps[0].spec, hosts: ["node-b"] }, req.scope, f.s), /outside task/);
138
+ f.s.deployments.provider = { deployment_id: "provider", host_id: "node-b", project_id: "app-b", status: "completed" };
139
+ f.s.allocations.db = { allocation_id: "db", provider_deployment_id: "provider", owner_project_id: "app-a", shared_owners: [] };
140
+ assert.throws(() => assertSpecScope({ schema_version: 1, worker: "D", operation: "deploy", provision: [{ allocation_id: "db" }] }, { ...req.scope, scope: "project", project_ids: ["app-a"], related_project_ids: ["app-b"] }, f.s), /outside task/);
141
+ f.s.deployments.consumer = { deployment_id: "consumer", host_id: "node-a", project_id: "app-a", status: "completed" };
142
+ f.s.bindings.link = { binding_id: "link", provider_deployment_id: "provider", consumer_deployment_id: "consumer", status: "active" };
143
+ assert.throws(() => assertSpecScope(req.steps[0].spec, { ...req.scope, related_project_ids: ["app-a", "app-b"] }, f.s), /outside task/);
144
+ });
145
+ test("task stops on expiry, executor, credential, input and catalog drift before any execution", async (t) => {
146
+ for (const kind of ["expiry", "engine", "ledger", "input", "catalog"]) {
147
+ await t.test(kind, async (sub) => {
148
+ const f = fixture(sub), a = adapterFor(f), req = request();
149
+ const input = join(f.root, "installer.bin"); writeFileSync(input, "reviewed"); req.input_paths = [input];
150
+ if (kind === "expiry") {
151
+ const p = await prepareTask(f.state, req, a.adapter); const path = join(f.state, "records/tasks/task-one/task.json"), task = JSON.parse(readFileSync(path, "utf8")); task.expires_at = "2020-01-01T00:00:00Z"; writeJson(path, task);
152
+ assert.throws(() => authorizeTask(f.state, req.task_id, digest(task), "test-user", "explicit confirmed task"), /expired/); assert.ok(p.task_digest); return;
153
+ }
154
+ await authorized(f, req, a.adapter);
155
+ if (kind === "engine") a.changeEngine(); if (kind === "ledger") a.changeLedger(); if (kind === "input") writeFileSync(input, "modified"); if (kind === "catalog") { f.s.revision++; f.save(); }
156
+ await assert.rejects(runTask(f.state, req.task_id, a.adapter), /drift|changed/); assert.equal(a.calls.apply, 0);
157
+ });
158
+ }
159
+ });
160
+ test("destructive and policy changes need explicitly bounded authorization", async (t) => {
161
+ const f = fixture(t), a = adapterFor(f), req = request(); req.steps[0].spec.operation = "migrate";
162
+ await assert.rejects(prepareTask(f.state, req, a.adapter), /destructive/);
163
+ assert.throws(() => assertSpecScope({ worker: "D", operation: "deploy", retire_deployments: ["app-a-prod"] }, { ...request().scope, scope: "project", project_ids: ["app-a"] }, f.s), /destructive/);
164
+ req.scope.allow_destructive = true; const p = await prepareTask(f.state, req, a.adapter);
165
+ assert.throws(() => authorizeTask(f.state, req.task_id, p.task_digest, "test-user", "explicit confirmed task"), /independently explicit/);
166
+ assert.throws(() => assertSpecScope({ ...request().steps[0].spec, plaintext_documentation: true }, request().scope, f.s), /policy change/);
167
+ });
168
+ test("compiled scope escalation and unpinned artifacts stop before approval and preserve plan references", async (t) => {
169
+ for (const kind of ["target", "source", "hidden-catalog"]) await t.test(kind, async sub => {
170
+ const f = fixture(sub), a = adapterFor(f, { plan: p => {
171
+ if (kind === "target") p.hosts["node-b"] = f.s.hosts["node-b"];
172
+ if (kind === "source") p.source_digests[join(f.root, "unreviewed")] = "a".repeat(64);
173
+ if (kind === "hidden-catalog") p.registry_after.projects["app-b"].service_type = "changed";
174
+ } }); await authorized(f, request(), a.adapter);
175
+ const r = await runTask(f.state, "task-one", a.adapter); assert.equal(r.status, "blocked"); assert.equal(a.calls.approve, 0); assert.equal(a.calls.apply, 0); assert.equal(r.steps[0].run_id, "run-1");
176
+ });
177
+ });
178
+ test("failed, unknown and docs_pending results stop later steps and cannot replay", async (t) => {
179
+ for (const outcome of ["failed", "unknown", "docs_pending", "throw"]) await t.test(outcome, async sub => {
180
+ const f = fixture(sub), a = adapterFor(f, { result: outcome, throwApply: outcome === "throw" }), req = request(); req.steps.push({ ...structuredClone(req.steps[0]), id: "next-step" });
181
+ await authorized(f, req, a.adapter); const r = await runTask(f.state, req.task_id, a.adapter);
182
+ assert.equal(r.status, outcome === "throw" ? "unknown" : outcome); assert.equal(a.calls.apply, 1); assert.equal(a.calls.compile, 1);
183
+ await assert.rejects(runTask(f.state, req.task_id, a.adapter), /already started/);
184
+ });
185
+ });
186
+ test("fleet projects both directions, host services and external providers without exporting secret fields", (t) => {
187
+ const f = fixture(t), sentinel = "NEVER-EXPORT-THIS-SECRET";
188
+ f.s.hosts["node-a"].connection.password = sentinel; f.s.hosts["node-a"].host_services = [{ id: "edge", kind: "nginx", notes: sentinel, public_url: `https://invalid/?token=${sentinel}` }];
189
+ f.s.projects["app-a"].source = { repository: `https://user:${sentinel}@invalid` };
190
+ f.s.deployments["app-prod"] = { deployment_id: "app-prod", host_id: "node-a", project_id: "app-a", status: "docs_pending", version: "v2", observed_version: "v1", root: "/srv/ops/app-a", env: { PASSWORD: sentinel }, notes: [sentinel], credential_refs: [sentinel] };
191
+ f.s.bindings.external = { binding_id: "external", consumer_deployment_id: "app-prod", provider_deployment_id: null, mode: "external", status: "active", credential_ref: sentinel };
192
+ const d = projectFleet(f.s); assert.deepEqual(d.servers[0].project_ids, ["app-a"]); assert.deepEqual(d.projects[0].server_ids, ["node-a"]); assert.equal(d.servers[0].host_services[0].service_id, "edge");
193
+ assert.equal(d.deployments[0].desired_version, "v2"); assert.equal(d.deployments[0].observed_version, "v1"); assert.equal(d.bindings[0].provider_deployment_id, null);
194
+ assert.ok(!JSON.stringify(d).includes(sentinel)); assert.ok(!renderHtml(d).includes(sentinel)); assert.ok(!renderMarkdown(d).includes(sentinel));
195
+ });
196
+ test("HTML rendering is deterministic, escaped, offline and CSP hashes match executable bytes", (t) => {
197
+ const f = fixture(t); f.s.hosts["node-a"].display_name = '</script><img src=x onerror="alert(1)">'; const d = projectFleet(f.s, {}, [], { at: new Date("2026-01-01") });
198
+ const html = renderHtml(d); assert.equal(html, renderHtml(d)); assert.ok(!html.includes(f.s.hosts["node-a"].display_name)); assert.ok(html.includes("\\u003c/script\\u003e")); assert.ok(!html.includes("innerHTML")); assert.ok(!html.includes("fetch("));
199
+ const script = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)].find(m => m[0].startsWith("<script>"))[1];
200
+ assert.ok(html.includes("sha256-" + createHash("sha256").update(script).digest("base64"))); assert.match(html, /connect-src 'none'/);
201
+ const md = renderMarkdown(d); assert.ok(!md.includes("<img"));
202
+ });
203
+ test("fleet writes immutable generations, verifies readback and retains one consistent landing page", async (t) => {
204
+ const f = fixture(t), at = new Date("2026-01-01T00:00:00Z");
205
+ // A secret ledger is deliberately unreadable JSON. View generation must not open it.
206
+ mkdirSync(join(f.state, "private")); writeFileSync(join(f.state, "private/credentials.json"), "NOT JSON, DO NOT READ");
207
+ const a = adapterFor(f); await authorized(f, request(), a.adapter);
208
+ const first = generateFleet(f.state, { at }), second = generateFleet(f.state, { at }); assert.equal(first.view_id, second.view_id);
209
+ assert.ok(readFileSync(first.markdown, "utf8").includes(first.view_id)); assert.ok(existsSync(first.html));
210
+ const before = readFileSync(first.markdown, "utf8"); writeFileSync(first.html, "tampered"); assert.throws(() => generateFleet(f.state, { at }), /readback mismatch/); assert.equal(readFileSync(first.markdown, "utf8"), before);
211
+ });
212
+ test("fleet refuses symlink outputs and catalog locks without overwriting old entry", (t) => {
213
+ const f = fixture(t), outside = join(f.root, "outside.txt"); writeFileSync(outside, "untouched"); symlinkSync(outside, join(f.state, "FLEET.md"));
214
+ assert.throws(() => generateFleet(f.state), /symlink/); assert.equal(readFileSync(outside, "utf8"), "untouched");
215
+ rmSync(join(f.state, "FLEET.md")); mkdirSync(join(f.state, ".locks/catalog"), { recursive: true }); assert.throws(() => generateFleet(f.state), /catalog lock/);
216
+ });
217
+
218
+
219
+ test("workspace task and fleet integrate with the real legacy planner/approval/apply gateway", async (t) => {
220
+ // A partial source overlay can run pure tests; installed-repository CI must exercise this branch.
221
+ const modelPath = resolve(dirname(fileURLToPath(import.meta.url)), "../tools/opslib/model.mjs");
222
+ if (!existsSync(modelPath)) { t.skip("legacy runtime absent in partial source overlay; full repository CI required"); return; }
223
+ const f = fixture(t);
224
+ const { fingerprint } = await import("../tools/opslib/agent.mjs");
225
+ const { validate } = await import("../tools/opslib/model.mjs");
226
+ const example = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/task-host.example.json"), "utf8"));
227
+ validate(example.steps[0].spec, "spec");
228
+ f.s.hosts["node-a"].identity = fingerprint(); f.save();
229
+ writeJson(join(f.state, "private/credentials.json"), { schema_version: 1, entries: {} });
230
+ const p = await prepareTask(f.state, request());
231
+ authorizeTask(f.state, "task-one", p.task_digest, "local-test-user", "Execute only this temporary local test directory preparation");
232
+ const r = await runTask(f.state, "task-one");
233
+ assert.equal(r.status, "completed", JSON.stringify(r));
234
+ assert.ok(existsSync(join(f.s.hosts["node-a"].root, "_host/cache")));
235
+ const view = generateFleet(f.state); assert.equal(view.status, "generated");
236
+ const data = JSON.parse(readFileSync(view.json, "utf8")); assert.equal(data.tasks[0].status, "completed");
237
+ assert.ok(data.releases.some(v => v.run_id === r.steps[0].run_id));
238
+ });
239
+
240
+ test("H and D cannot be mislabeled to cross server/project boundaries", (t) => {
241
+ const f = fixture(t), s = request().scope;
242
+ const d = { deployment_id: "app-prod", host_id: "node-a", project_id: "app-a" };
243
+ assert.throws(() => assertSpecScope({ worker: "H", deployments: [d] }, { ...s, related_project_ids: ["app-a"] }, f.s), /H cannot/);
244
+ assert.throws(() => assertSpecScope({ worker: "D", host_actions: [{ host_id: "node-a", kind: "mkdir" }] }, { ...s, scope: "project", project_ids: ["app-a"] }, f.s), /D cannot/);
245
+ assert.doesNotThrow(() => assertSpecScope({ worker: "D", operation: "deploy", deployments: [{ ...d, compose: { services: { app: { environment: { project_id: "vendor-data-not-a-resource", host_id: "vendor-setting" } } } } }] }, { ...s, scope: "project", project_ids: ["app-a"] }, f.s));
246
+ });
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
- /** OPS entry point: Node >=22, no third-party packages. */
3
- import { main } from "./opslib/cli.mjs";
4
- process.exit(main(process.argv.slice(2)));
2
+ /** OPS entry point: Node >=22.22.3 <25, no third-party packages. */
3
+ import { workspaceCommand } from "./opslib/workspace_cli.mjs";
4
+ const argv = process.argv.slice(2);
5
+ const { main } = await import(workspaceCommand(argv) ? "./opslib/workspace_cli.mjs" : "./opslib/cli.mjs");
6
+ process.exitCode = await main(argv);