@abelxiaoxing/cadence 1.5.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -0
- package/package.json +1 -1
- package/src/apply-transaction.ts +1 -9
- package/src/artifact-store.ts +1 -9
- package/src/contracts.ts +16 -5
- package/src/design-diagnostics.ts +8 -0
- package/src/directory-sync.ts +36 -0
- package/src/index.ts +28 -0
- package/src/package-state.ts +309 -0
- package/src/package-workflow.ts +216 -171
- package/src/run-store.ts +39 -15
- package/src/storage-schema.ts +14 -6
- package/src/workflow-state-machine.ts +86 -51
- package/src/workspace-io-worker.mjs +48 -42
- package/src/workspace-store.ts +1 -9
package/README.md
CHANGED
|
@@ -95,6 +95,37 @@ Design 同任务续轮重新收紧允许的工具集合,预检失败或暂停
|
|
|
95
95
|
Implement 只暴露 `start`、`status`、`resume`、`rebind`、`cancel` 和 `discard` 控制命令。
|
|
96
96
|
`status` 完全本地可用;相同 operation id 幂等重放,进程、会话或 Worker 更换后仍从 durable checkpoint 继续。
|
|
97
97
|
Design 从第一步起统一使用 `action: "design"`:新需求通过 `start(requirement)` 进入,已有 change 通过 `start(change)` 进入,后续只携带返回的 `runId`。
|
|
98
|
+
|
|
99
|
+
### 升级后的私有状态重置
|
|
100
|
+
|
|
101
|
+
包服务首次打开当前工作区时,比较数据库记录的包版本与当前 `package.json.version`。
|
|
102
|
+
升级或旧库没有包版本标记时,自动将该工作区整个私有状态目录(SQLite/WAL、`run-data/` 下的候选工作区、产物和事务记录)移入同工作区专属备份目录,然后创建新库。
|
|
103
|
+
控制库损坏导致版本不可读、版本标记无效,或控制库缺失但仍有旧资源时,也整体备份并重新初始化,不尝试修复或解释旧运行。
|
|
104
|
+
可读有效版本标记与当前版本一致时保留状态;同版本号下替换代码不会触发重置,也不自动诊断其他表的损坏。
|
|
105
|
+
能读取有效旧版本时,降级拒绝打开,不自动清库;旧版本不可读时不声称能够判断升降级。
|
|
106
|
+
当前包版本仅接受当前发布使用的 `major.minor.patch` 格式。
|
|
107
|
+
安装包时不扫描用户目录,其他工作区各自在首次打开时处理;只读 `doctor` / `runs` 不触发重置。
|
|
108
|
+
|
|
109
|
+
**升级意味着旧运行不再可续用:运行、审批证明、进度、检查点和预算不继承到新库。**
|
|
110
|
+
仓库代码、未提交改动及 OpenSpec 文件不变,但旧交付缺少原私有证明,不能直接视为有效交付;需要重新建立可信交付。
|
|
111
|
+
备份不自动恢复或删除。
|
|
112
|
+
服务响应的 `packageStateReset` 显示原因、前后版本及备份路径(不可读旧版本为 `null`),并明确 `oldRunsAbandoned: true`、`workspaceRestored: false` 和风险提示,不输出数据库内容、环境变量值或凭据。
|
|
113
|
+
备份位于 `<roots>/<workspace-hash>.package-state/backup-<uuid>`,该目录中的 `lock.sqlite3` 和 `reset.json` 是协调与中断恢复记录,不应手工修改。
|
|
114
|
+
|
|
115
|
+
**重置前必须退出所有旧宿主及其执行进程;数据库记录为 running 或租约未过期,不等于进程仍存活,也不再作为结算门槛。**
|
|
116
|
+
新包使用工作区专属 SQLite 写锁,在整个服务生命周期内互斥;并发宿主或版本读取实际遇到 SQLite 锁时返回 `package-state-in-use`。
|
|
117
|
+
该锁不能约束不认识它的旧版本或自行脱离的后代进程,重置不代表已经终止这些进程。
|
|
118
|
+
旧任务、操作、租约以及缺失/损坏/未结算的事务记录一律随旧目录备份,不再要求先恢复或结算,也不由新库管理。
|
|
119
|
+
**重置不会回滚主工作区:此前 apply 中断可能留下部分改动,请先检查 Git diff,再开始新工作。**
|
|
120
|
+
真实权限、I/O 或备份发布失败仍报错;不能因想清库就绕过文件系统安全边界。
|
|
121
|
+
状态根、控制库路径及协调路径的符号链接仍拒绝;嵌套资源链接随目录重命名保留,不跟随其目标或删除目标内容。
|
|
122
|
+
备份不依赖读取旧的每运行事务数据库,包括 `run-data/<runId>/transactions/apply.sqlite3`。
|
|
123
|
+
可识别的降级返回 `package-state-downgrade-blocked`。
|
|
124
|
+
中断后同版本重试按持久化意图继续,不重复备份;未完成重置时不允许换另一个目标版本。
|
|
125
|
+
目录刷新沿用下文 Windows 能力边界,不声称 Windows 断电场景具备 POSIX 目录持久化保证。
|
|
126
|
+
|
|
127
|
+
底层 `RunStore` 仍保留经结构校验的旧 `schema_meta` v2/v4 原子迁移,供原版本恢复和独立存储调用使用;包服务的上述重置策略优先执行。
|
|
128
|
+
v2 迁移只补齐可空凭证字段且保持 `NULL`,不伪造审批;损坏或部分升级仍拒绝打开,迁移失败整体回滚。
|
|
98
129
|
需求、决策合同与 Gate A 合同由控制面规范化并计算哈希,调用方无需 SHA-256 工具;Gate B 自动绑定当前已编译 canonical plan,原始瞬时文本不会写入 durable journal。
|
|
99
130
|
`validate-plan-draft` 可在 `compile-plan` 前只读运行同一套编译检查,并以结构化 `taskId` / phase / field / verification 诊断定位问题;Design finalization 的安全诊断码也会进入错误详情与可展开 TUI,而不再只显示统一失败标题。
|
|
100
131
|
随包提供的 [单任务计划示例](config/plan-draft.example.json) 展示精简 PlanDraft 和结构化 Gate A 合同;按实际仓库替换示例中的路径、证据与验证命令。
|
|
@@ -150,6 +181,58 @@ Gate 等待和可恢复暂停保持激活以接收直接后续操作。
|
|
|
150
181
|
暂停会保留最小结构化恢复事实;完成或显式 discard 后安全清理。
|
|
151
182
|
不会持久化凭据、环境值、原始 prompt、隐藏推理、完整子会话或原始模型输出。
|
|
152
183
|
|
|
184
|
+
### 公共 UI 的 impactClosure 作者格式
|
|
185
|
+
|
|
186
|
+
`page-state` 与 `public-html` 可以同时声明。
|
|
187
|
+
以下是 **PlanDraft 任务片段**,合并到现有 `tasks[i]`,不是完整计划;其余 Gate A、阶段、产物和验证合同仍按单任务示例填写。
|
|
188
|
+
路径和证据仅用于说明,必须替换为仓库真实调查结果。
|
|
189
|
+
假设 Red 修改 `test/page-state.mjs`,已有 `test/public-html.mjs` 只读保留:
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
{
|
|
193
|
+
"read": ["test/page-state.mjs", "test/public-html.mjs"],
|
|
194
|
+
"impactClosure": {
|
|
195
|
+
"changedSurfaces": ["page-state", "public-html"],
|
|
196
|
+
"searchEvidence": [
|
|
197
|
+
"检索页面状态的调用点及 test/、tests/ 中的覆盖:test/page-state.mjs 需更新状态切换断言,test/public-html.mjs 覆盖现有公开 HTML。"
|
|
198
|
+
],
|
|
199
|
+
"relatedTests": [
|
|
200
|
+
{
|
|
201
|
+
"path": "test/page-state.mjs",
|
|
202
|
+
"evidence": "本任务 Red 更新页面状态切换回归;由阶段 write 推导为 current-task。"
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"path": "test/public-html.mjs",
|
|
206
|
+
"evidence": "保留已有 HTML 断言并执行受影响验证;无任务写入,推导为 unaffected。"
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
"affectedSuite": ["test/page-state.mjs", "test/public-html.mjs"]
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
完整规则:
|
|
215
|
+
|
|
216
|
+
- `impactClosure` 恰好包含 `changedSurfaces`、`searchEvidence`、`relatedTests`、`affectedSuite` 四个字段,不接受任意扩展字段。
|
|
217
|
+
- `changedSurfaces` 是非空、无重复的合法枚举数组;`none` 不能与其他值混用。
|
|
218
|
+
不得为了通过校验将公共影响改为 `none`。
|
|
219
|
+
- 存在公共影响时,后三个数组均不得为空;`searchEvidence` 每项为非空字符串,记录实际检索及结论,不是路径对象,也不是可省略的推导字段。
|
|
220
|
+
- `relatedTests` 每项包含 `path` 和非空字符串 `evidence`;路径无重复,必须是 `test/` 或 `tests/` 下的仓库相对文件路径,并位于本任务各阶段合并后的 read/write 范围。
|
|
221
|
+
`src/foo.test.ts` 不满足当前测试路径规则。
|
|
222
|
+
- PlanDraft 可省略 `disposition`:本任务写入则推导为 `current-task`,只有另一任务写入则为 `regression-task` 并推导 `regressionTaskId`,没有任务写入则为 `unaffected`;多个其他写入者时必须消除歧义。
|
|
223
|
+
`unaffected` 表示不修改该测试,不表示不运行该测试。
|
|
224
|
+
- 显式作者格式为 `{ "path": "test/page-state.mjs", "disposition": "current-task", "evidence": "实际证据" }`;只读保留用 `unaffected`。
|
|
225
|
+
跨任务格式为 `{ "path": "test/page-state.mjs", "disposition": "regression-task", "evidence": "实际证据", "regressionTaskId": "ui-regression" }`。
|
|
226
|
+
只有 `regression-task` 携带 `regressionTaskId`;依赖、产物和阶段权限仍需按实际计划声明,不会由闭包新增。
|
|
227
|
+
- `affectedSuite` 是无重复的测试路径数组,每项必须有对应的 `relatedTests` 条目;具备快照的运行时校验还要求其中至少一个是已有普通测试文件,不能全靠未来新增测试充当已有证据。
|
|
228
|
+
- `affectedSuite` 是影响清单,不是验证器。
|
|
229
|
+
显式 `affectedVerification` 应实际运行这些测试,例如 Vitest 的 `testFiles` 列出二者,或使用仓库真实的测试脚本。
|
|
230
|
+
仍需声明验证输入并遵守 Gate A,不能只补清单就宣称测试已运行。
|
|
231
|
+
|
|
232
|
+
`validate-plan-draft` 对三个空数组分别返回字段级 category:`search-evidence-required`、`related-tests-required`、`affected-suite-required`,并提供代码维护的 `hint`。
|
|
233
|
+
测试漏列仍返回 `impactClosure.affectedSuite.<index>` / `related-test-missing` 和具体 `path`。
|
|
234
|
+
空数组不足以确定应选哪些测试,因此不会虚构 `expectedPaths`;按实际证据补齐后,在同一个 Design run 重试预检。
|
|
235
|
+
|
|
153
236
|
## 内部边界与运行验证
|
|
154
237
|
|
|
155
238
|
扩展入口负责宿主生命周期与服务装配,交付加载和验证适配器分别由独立模块负责。
|
|
@@ -242,6 +325,18 @@ CLI 的其他版本必须满足相同 JSON 协议;新增支持版本应加入
|
|
|
242
325
|
|
|
243
326
|
**此矩阵不代表原生 Implement 隔离已全平台可用。**
|
|
244
327
|
默认隔离后端仍为 Linux Bubblewrap;Windows/macOS 的原生隔离、ACL 与完整文件应用语义需要单独实现和验收,缺少隔离能力时保持暂停,不降级为主工作区直接执行。
|
|
328
|
+
|
|
329
|
+
目录持久化在 Windows 上单独处理:仅当已打开的目录句柄执行 `fsync` 返回 `EPERM` 时,标记为不支持目录刷新屏障。
|
|
330
|
+
普通文件仍须在发布前成功 `fsync`;目录打开/关闭、权限、其他 I/O 及 POSIX 目录刷新错误不会被忽略。
|
|
331
|
+
内容哈希校验、原子发布、SQLite 事务意图、CAS 与回滚记录继续生效;Windows 的进程中断恢复不等于 POSIX 目录屏障提供的断电持久性保证。
|
|
332
|
+
|
|
333
|
+
以下是底层同版本恢复语义,不是跨包版本保留运行的承诺;跨版本以“升级后的私有状态重置”策略为准,旧运行会被放弃而非恢复。
|
|
334
|
+
若同版本运行在存储异常后停留于 `running / phase-running`,请先停止旧宿主,完整备份 owner-private state root(含 SQLite、WAL 及 artifacts/workspaces),再加载同版本修复。
|
|
335
|
+
用原工作区和原 state root 查询同一 change;重新打开时会在确认无有效执行租约后将遗留运行转为 `paused / operation-interrupted`,apply 中断则进入 `recovering`。
|
|
336
|
+
按本地 `status` 提供的 `resume` 继续原运行,不需要重新 Design、重新 start、discard 或手动修改数据库。
|
|
337
|
+
恢复保留交付绑定、阶段检查点、验证证据和已消耗预算,不返还预算、不伪造 Red/Green 结果;下一次执行仍须满足原有环境和隔离要求。
|
|
338
|
+
不要为绕过 Windows 隔离限制而直接移动状态目录或更改工作区身份。
|
|
339
|
+
|
|
245
340
|
可信 Linux 项目可以显式选择上面的 `local-trusted` 候选目录执行模式。
|
|
246
341
|
|
|
247
342
|
本地可用 `CADENCE_REAL_OPENSPEC=1` 启用真实 CLI 测试(Windows 可用 PowerShell 设置 `$env:CADENCE_REAL_OPENSPEC = "1"`),然后运行:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abelxiaoxing/cadence",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Four explicit Abel workflow prompts with stage-isolated design and diagnosis, durable resumable implementation, and three package-owned professional Agents.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/apply-transaction.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
23
23
|
import type { ArtifactStore } from "./artifact-store.ts";
|
|
24
24
|
import { compareCanonicalStrings } from "./canonical.ts";
|
|
25
25
|
import { isValidRelativePath } from "./contracts.ts";
|
|
26
|
+
import { syncDirectory } from "./directory-sync.ts";
|
|
26
27
|
import { observeSafePath } from "./safe-path.ts";
|
|
27
28
|
import { configureSqlite, ensureSqliteSchema } from "./sqlite-schema.ts";
|
|
28
29
|
import { APPLY_SCHEMA } from "./storage-schema.ts";
|
|
@@ -247,15 +248,6 @@ function ensurePrivateDirectory(directory: string): void {
|
|
|
247
248
|
chmodSync(directory, 0o700);
|
|
248
249
|
}
|
|
249
250
|
|
|
250
|
-
function syncDirectory(directory: string): void {
|
|
251
|
-
const descriptor = openSync(directory, constants.O_RDONLY);
|
|
252
|
-
try {
|
|
253
|
-
fsyncSync(descriptor);
|
|
254
|
-
} finally {
|
|
255
|
-
closeSync(descriptor);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
251
|
function entryEqual(left: WorkspaceEntry, right: WorkspaceEntry): boolean {
|
|
260
252
|
if (left.kind !== right.kind) return false;
|
|
261
253
|
return (
|
package/src/artifact-store.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
writeFileSync,
|
|
15
15
|
} from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
|
+
import { syncDirectory } from "./directory-sync.ts";
|
|
17
18
|
|
|
18
19
|
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
19
20
|
|
|
@@ -58,15 +59,6 @@ function ensureDirectory(directory: string): void {
|
|
|
58
59
|
chmodSync(directory, 0o700);
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
function syncDirectory(directory: string): void {
|
|
62
|
-
const descriptor = openSync(directory, constants.O_RDONLY);
|
|
63
|
-
try {
|
|
64
|
-
fsyncSync(descriptor);
|
|
65
|
-
} finally {
|
|
66
|
-
closeSync(descriptor);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
62
|
function writeAtomic(target: string, bytes: Uint8Array): void {
|
|
71
63
|
const directory = path.dirname(target);
|
|
72
64
|
ensureDirectory(directory);
|
package/src/contracts.ts
CHANGED
|
@@ -1574,12 +1574,23 @@ function taskFieldDiagnostics(
|
|
|
1574
1574
|
allowedValues: [...IMPACT_SURFACES],
|
|
1575
1575
|
});
|
|
1576
1576
|
}
|
|
1577
|
+
const publicImpact = closure.changedSurfaces.some(
|
|
1578
|
+
(surface) =>
|
|
1579
|
+
surface !== "none" &&
|
|
1580
|
+
(IMPACT_SURFACES as readonly unknown[]).includes(surface),
|
|
1581
|
+
);
|
|
1582
|
+
if (publicImpact) {
|
|
1583
|
+
for (const [field, category] of [
|
|
1584
|
+
["searchEvidence", "search-evidence-required"],
|
|
1585
|
+
["relatedTests", "related-tests-required"],
|
|
1586
|
+
["affectedSuite", "affected-suite-required"],
|
|
1587
|
+
] as const) {
|
|
1588
|
+
if (Array.isArray(closure[field]) && closure[field].length === 0)
|
|
1589
|
+
add({ field: `impactClosure.${field}`, category });
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1577
1592
|
if (
|
|
1578
|
-
|
|
1579
|
-
(surface) =>
|
|
1580
|
-
surface !== "none" &&
|
|
1581
|
-
(IMPACT_SURFACES as readonly unknown[]).includes(surface),
|
|
1582
|
-
) &&
|
|
1593
|
+
publicImpact &&
|
|
1583
1594
|
Array.isArray(closure.affectedSuite) &&
|
|
1584
1595
|
Array.isArray(closure.relatedTests)
|
|
1585
1596
|
) {
|
|
@@ -26,6 +26,14 @@ const HINTS = new Map<string, string>(
|
|
|
26
26
|
"duplicate-path":
|
|
27
27
|
"Remove the repeated path at the indicated index; keep the existing declaration and do not widen phase permissions.",
|
|
28
28
|
enum: "Use one of allowedValues at the indicated field. For public impact, choose the actual changed surface; do not substitute none to bypass closure checks.",
|
|
29
|
+
"public-impact-incomplete":
|
|
30
|
+
"Public impact requires non-empty searchEvidence, relatedTests and affectedSuite; every affectedSuite path must have a relatedTests entry. Keep the actual changedSurfaces; do not substitute none.",
|
|
31
|
+
"search-evidence-required":
|
|
32
|
+
"Add non-empty searchEvidence strings describing actual searches of changed public surfaces, callers and existing tests. Do not invent evidence or substitute none for changedSurfaces.",
|
|
33
|
+
"related-tests-required":
|
|
34
|
+
"Add relatedTests entries with path and non-empty evidence for actual tests under test/ or tests/, within the task's declared read/write scope. In PlanDraft omit disposition to derive ownership from task writes; do not widen writes or substitute none.",
|
|
35
|
+
"affected-suite-required":
|
|
36
|
+
"Add actual affected test paths under test/ or tests/ to affectedSuite and declare each in relatedTests. Bind their execution in affectedVerification; retained snapshot validation requires at least one existing test. Do not invent paths or substitute none.",
|
|
29
37
|
"related-test-missing":
|
|
30
38
|
"Add this affected-suite test to relatedTests with evidence and its actual owner. Do not remove public impact or widen writes to satisfy ownership.",
|
|
31
39
|
"change-contract-acceptance-missing":
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, fsyncSync, openSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flush a published directory entry where Node supports it. Windows rejects
|
|
5
|
+
* FlushFileBuffers on Node's read-only directory handle with EPERM. This is
|
|
6
|
+
* specifically a missing directory barrier, NOT permission to ignore file
|
|
7
|
+
* flush, open, close, rename or other I/O errors.
|
|
8
|
+
*
|
|
9
|
+
* Callers must flush file bytes before publishing and retain their integrity
|
|
10
|
+
* checks / write-ahead recovery records. On Windows this provides process-crash
|
|
11
|
+
* recovery, not a POSIX directory-fsync guarantee across sudden power loss.
|
|
12
|
+
*/
|
|
13
|
+
export function syncDirectory(
|
|
14
|
+
directory: string,
|
|
15
|
+
): "synced" | "unsupported-directory-barrier" {
|
|
16
|
+
const descriptor = openSync(directory, constants.O_RDONLY);
|
|
17
|
+
try {
|
|
18
|
+
try {
|
|
19
|
+
fsyncSync(descriptor);
|
|
20
|
+
return "synced";
|
|
21
|
+
} catch (error) {
|
|
22
|
+
const failure = error as NodeJS.ErrnoException | null;
|
|
23
|
+
if (
|
|
24
|
+
process.platform === "win32" &&
|
|
25
|
+
failure?.code === "EPERM" &&
|
|
26
|
+
failure.syscall === "fsync" &&
|
|
27
|
+
fstatSync(descriptor).isDirectory()
|
|
28
|
+
) {
|
|
29
|
+
return "unsupported-directory-barrier";
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
} finally {
|
|
34
|
+
closeSync(descriptor);
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
projectDesignDiagnostic,
|
|
5
5
|
type SafeDesignDiagnostic,
|
|
6
6
|
} from "./design-diagnostics.ts";
|
|
7
|
+
import { PackageStateError } from "./package-state.ts";
|
|
7
8
|
import { openPackageWorkflowService } from "./package-workflow.ts";
|
|
8
9
|
import { packageContext } from "./pi-adapter.ts";
|
|
9
10
|
|
|
@@ -457,6 +458,14 @@ function classifyDesignFailure(
|
|
|
457
458
|
field?: string;
|
|
458
459
|
} {
|
|
459
460
|
const message = error instanceof Error ? error.message : "";
|
|
461
|
+
if (error instanceof PackageStateError) {
|
|
462
|
+
return {
|
|
463
|
+
code: error.code,
|
|
464
|
+
category: "storage",
|
|
465
|
+
retryable: false,
|
|
466
|
+
field: "packageVersion",
|
|
467
|
+
};
|
|
468
|
+
}
|
|
460
469
|
if (error instanceof DesignControlValidationError) {
|
|
461
470
|
return {
|
|
462
471
|
code: "invalid-design-control-request",
|
|
@@ -1229,6 +1238,25 @@ export function registerWorkflowControl(
|
|
|
1229
1238
|
if (exitingStage || !activation.isActive())
|
|
1230
1239
|
throw new Error("stage-control-mismatch");
|
|
1231
1240
|
} catch (error) {
|
|
1241
|
+
if (error instanceof PackageStateError) {
|
|
1242
|
+
const payload = {
|
|
1243
|
+
stage: "abel-implement",
|
|
1244
|
+
change: validation.value.change,
|
|
1245
|
+
state: "paused",
|
|
1246
|
+
durable: false,
|
|
1247
|
+
completed: false,
|
|
1248
|
+
pause: { code: error.code },
|
|
1249
|
+
legalCommands: ["status", "start"],
|
|
1250
|
+
tasks: [],
|
|
1251
|
+
queue: [],
|
|
1252
|
+
};
|
|
1253
|
+
return {
|
|
1254
|
+
content: [
|
|
1255
|
+
{ type: "text" as const, text: JSON.stringify(payload) },
|
|
1256
|
+
],
|
|
1257
|
+
details: payload,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1232
1260
|
if (!(error instanceof RunStoreFormatError)) throw error;
|
|
1233
1261
|
const payload = runStoreUnavailableStatus(error, validation.value);
|
|
1234
1262
|
return {
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { DatabaseSync } from "node:sqlite";
|
|
17
|
+
import { syncDirectory } from "./directory-sync.ts";
|
|
18
|
+
import { RunStore } from "./run-store.ts";
|
|
19
|
+
import { prepareStateRoot, type ResolvedStateRoot } from "./state-root.ts";
|
|
20
|
+
|
|
21
|
+
export class PackageStateError extends Error {
|
|
22
|
+
readonly code: string;
|
|
23
|
+
constructor(code: string) {
|
|
24
|
+
super(code);
|
|
25
|
+
this.code = code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export interface PackageStateNotice {
|
|
29
|
+
reason:
|
|
30
|
+
| "package-upgrade"
|
|
31
|
+
| "unversioned-store"
|
|
32
|
+
| "unreadable-store"
|
|
33
|
+
| "missing-store";
|
|
34
|
+
previousVersion: string | null;
|
|
35
|
+
currentVersion: string;
|
|
36
|
+
backupPath: string;
|
|
37
|
+
oldRunsAbandoned: true;
|
|
38
|
+
workspaceRestored: false;
|
|
39
|
+
warning: string;
|
|
40
|
+
}
|
|
41
|
+
interface ResetIntent
|
|
42
|
+
extends Omit<
|
|
43
|
+
PackageStateNotice,
|
|
44
|
+
"oldRunsAbandoned" | "workspaceRestored" | "warning"
|
|
45
|
+
> {
|
|
46
|
+
backupName: string;
|
|
47
|
+
}
|
|
48
|
+
const fail = (code: string): never => {
|
|
49
|
+
throw new PackageStateError(code);
|
|
50
|
+
};
|
|
51
|
+
const versionParts = (version: string): number[] => {
|
|
52
|
+
if (!/^\d+\.\d+\.\d+$/u.test(version)) fail("package-state-version-invalid");
|
|
53
|
+
const parts = version.split(".").map(Number);
|
|
54
|
+
if (parts.some((part) => !Number.isSafeInteger(part)))
|
|
55
|
+
fail("package-state-version-invalid");
|
|
56
|
+
return parts;
|
|
57
|
+
};
|
|
58
|
+
function compareVersions(left: string, right: string): number {
|
|
59
|
+
const a = versionParts(left),
|
|
60
|
+
b = versionParts(right);
|
|
61
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1;
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
function regular(file: string): boolean {
|
|
65
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
66
|
+
if (!stat) return false;
|
|
67
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
68
|
+
fail("package-state-path-unsafe");
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
function table(db: DatabaseSync, name: string): boolean {
|
|
72
|
+
return !!db
|
|
73
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
74
|
+
.get(name);
|
|
75
|
+
}
|
|
76
|
+
function storedVersion(db: DatabaseSync): string | null {
|
|
77
|
+
if (!table(db, "cadence_package_version")) return null;
|
|
78
|
+
const rows = db.prepare("SELECT version FROM cadence_package_version").all();
|
|
79
|
+
if (rows.length !== 1 || typeof rows[0].version !== "string")
|
|
80
|
+
fail("package-state-version-invalid");
|
|
81
|
+
const version = String(rows[0].version);
|
|
82
|
+
versionParts(version);
|
|
83
|
+
return version;
|
|
84
|
+
}
|
|
85
|
+
// Only inspect the version. Old task/lease/apply records are deliberately not
|
|
86
|
+
// interpreted: this policy abandons them, including malformed or unreadable facts.
|
|
87
|
+
function inspectVersion(file: string): {
|
|
88
|
+
version: string | null;
|
|
89
|
+
unreadable: boolean;
|
|
90
|
+
} {
|
|
91
|
+
let db: DatabaseSync | undefined;
|
|
92
|
+
try {
|
|
93
|
+
db = new DatabaseSync(file, { readOnly: true });
|
|
94
|
+
return { version: storedVersion(db), unreadable: false };
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const failure = error as { errcode?: number; code?: string };
|
|
97
|
+
// An actual SQLite lock is not a stale durable lease record. Do not move
|
|
98
|
+
// storage while an uncoordinated old opener is observably using it.
|
|
99
|
+
if (failure.errcode !== undefined && [5, 6].includes(failure.errcode & 255))
|
|
100
|
+
fail("package-state-in-use");
|
|
101
|
+
if (
|
|
102
|
+
(error instanceof PackageStateError &&
|
|
103
|
+
error.code === "package-state-version-invalid") ||
|
|
104
|
+
(failure.errcode !== undefined &&
|
|
105
|
+
[1, 11, 26].includes(failure.errcode & 255))
|
|
106
|
+
)
|
|
107
|
+
return { version: null, unreadable: true };
|
|
108
|
+
// Schema errors / SQLITE_CORRUPT / SQLITE_NOTADB mean opaque old bytes.
|
|
109
|
+
// Permissions, disk I/O, resource exhaustion and unknown errors do not.
|
|
110
|
+
return fail("package-state-reset-unavailable");
|
|
111
|
+
} finally {
|
|
112
|
+
db?.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasOldResources(state: ResolvedStateRoot): boolean {
|
|
117
|
+
// Cover the production run-data layout and unknown historical resources.
|
|
118
|
+
// Never descend through resource links; the whole root is renamed as-is.
|
|
119
|
+
const emptyScaffolds = new Set(["artifacts", "workspaces", "transactions"]);
|
|
120
|
+
return readdirSync(state.rootDir).some((name) => {
|
|
121
|
+
const file = path.join(state.rootDir, name);
|
|
122
|
+
const stat = lstatSync(file);
|
|
123
|
+
return (
|
|
124
|
+
!emptyScaffolds.has(name) ||
|
|
125
|
+
!stat.isDirectory() ||
|
|
126
|
+
stat.isSymbolicLink() ||
|
|
127
|
+
readdirSync(file).length > 0
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const RESET_WARNING =
|
|
133
|
+
"Old runs and approval evidence are abandoned, not resumed. Stop all old hosts and execution processes before resetting; the lock cannot fence older versions or detached descendants. The main workspace is not restored: interrupted apply may have left partial changes. Inspect Git diff before starting new work.";
|
|
134
|
+
|
|
135
|
+
function persistIntent(file: string, intent: ResetIntent): void {
|
|
136
|
+
const temp = `${file}.tmp`;
|
|
137
|
+
regular(temp);
|
|
138
|
+
const fd = openSync(temp, "w", 0o600);
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(fd, JSON.stringify(intent));
|
|
141
|
+
fsyncSync(fd);
|
|
142
|
+
} finally {
|
|
143
|
+
closeSync(fd);
|
|
144
|
+
}
|
|
145
|
+
renameSync(temp, file);
|
|
146
|
+
syncDirectory(path.dirname(file));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Package boundary only: low-level storage and read-only operator inspection never reset state.
|
|
150
|
+
* The external SQLite write lock is held until every service connection has closed.
|
|
151
|
+
* SQLite releases it on process death; no PID guessing or stale-lock deletion is used.
|
|
152
|
+
*/
|
|
153
|
+
export function acquirePackageState(
|
|
154
|
+
unresolved: ResolvedStateRoot,
|
|
155
|
+
version = String(
|
|
156
|
+
JSON.parse(
|
|
157
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
158
|
+
).version,
|
|
159
|
+
),
|
|
160
|
+
): { notice?: PackageStateNotice; close(): void } {
|
|
161
|
+
versionParts(version);
|
|
162
|
+
const state = prepareStateRoot(unresolved);
|
|
163
|
+
for (const suffix of ["-wal", "-shm", "-journal"])
|
|
164
|
+
regular(state.databasePath + suffix);
|
|
165
|
+
const coordinator = path.join(
|
|
166
|
+
state.rootsDir,
|
|
167
|
+
`${state.consumerRootHash}.package-state`,
|
|
168
|
+
);
|
|
169
|
+
const stat = lstatSync(coordinator, { throwIfNoEntry: false });
|
|
170
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
|
|
171
|
+
fail("package-state-path-unsafe");
|
|
172
|
+
mkdirSync(coordinator, { recursive: true, mode: 0o700 });
|
|
173
|
+
const lockFile = path.join(coordinator, "lock.sqlite3");
|
|
174
|
+
for (const suffix of ["", "-journal", "-wal", "-shm"])
|
|
175
|
+
regular(lockFile + suffix);
|
|
176
|
+
if (!existsSync(lockFile)) {
|
|
177
|
+
try {
|
|
178
|
+
closeSync(openSync(lockFile, "wx", 0o600));
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const lock = new DatabaseSync(lockFile);
|
|
184
|
+
let closed = false;
|
|
185
|
+
const close = () => {
|
|
186
|
+
if (!closed) {
|
|
187
|
+
closed = true;
|
|
188
|
+
lock.close();
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
lock.exec("PRAGMA busy_timeout = 1; BEGIN IMMEDIATE");
|
|
193
|
+
} catch {
|
|
194
|
+
close();
|
|
195
|
+
fail("package-state-in-use");
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const intentFile = path.join(coordinator, "reset.json");
|
|
199
|
+
let intent: ResetIntent | undefined;
|
|
200
|
+
if (regular(intentFile)) {
|
|
201
|
+
intent = JSON.parse(readFileSync(intentFile, "utf8")) as ResetIntent;
|
|
202
|
+
if (
|
|
203
|
+
!intent ||
|
|
204
|
+
!/^backup-[a-f0-9-]{36}$/u.test(intent.backupName) ||
|
|
205
|
+
intent.backupPath !== path.join(coordinator, intent.backupName) ||
|
|
206
|
+
![
|
|
207
|
+
"package-upgrade",
|
|
208
|
+
"unversioned-store",
|
|
209
|
+
"unreadable-store",
|
|
210
|
+
"missing-store",
|
|
211
|
+
].includes(intent.reason) ||
|
|
212
|
+
(intent.previousVersion !== null &&
|
|
213
|
+
typeof intent.previousVersion !== "string")
|
|
214
|
+
)
|
|
215
|
+
fail("package-state-reset-intent-invalid");
|
|
216
|
+
if (intent.currentVersion !== version)
|
|
217
|
+
fail("package-state-reset-version-mismatch");
|
|
218
|
+
}
|
|
219
|
+
let fresh = !regular(state.databasePath);
|
|
220
|
+
if (!intent) {
|
|
221
|
+
const observed = fresh
|
|
222
|
+
? { version: null, unreadable: false }
|
|
223
|
+
: inspectVersion(state.databasePath);
|
|
224
|
+
if (observed.version === version) return { close };
|
|
225
|
+
if (
|
|
226
|
+
observed.version !== null &&
|
|
227
|
+
compareVersions(observed.version, version) > 0
|
|
228
|
+
)
|
|
229
|
+
fail("package-state-downgrade-blocked");
|
|
230
|
+
if (!fresh || hasOldResources(state)) {
|
|
231
|
+
const backupName = `backup-${randomUUID()}`;
|
|
232
|
+
intent = {
|
|
233
|
+
backupName,
|
|
234
|
+
backupPath: path.join(coordinator, backupName),
|
|
235
|
+
previousVersion: observed.version,
|
|
236
|
+
currentVersion: version,
|
|
237
|
+
reason: fresh
|
|
238
|
+
? "missing-store"
|
|
239
|
+
: observed.unreadable
|
|
240
|
+
? "unreadable-store"
|
|
241
|
+
: observed.version === null
|
|
242
|
+
? "unversioned-store"
|
|
243
|
+
: "package-upgrade",
|
|
244
|
+
};
|
|
245
|
+
persistIntent(intentFile, intent);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (intent) {
|
|
249
|
+
const backup = lstatSync(intent.backupPath, { throwIfNoEntry: false });
|
|
250
|
+
if (backup && (!backup.isDirectory() || backup.isSymbolicLink()))
|
|
251
|
+
fail("package-state-path-unsafe");
|
|
252
|
+
if (!backup) {
|
|
253
|
+
// Abandon the old namespace as opaque bytes, including run-data and
|
|
254
|
+
// SQLite sidecars. No transaction replay or main-workspace rollback.
|
|
255
|
+
renameSync(state.rootDir, intent.backupPath);
|
|
256
|
+
syncDirectory(coordinator);
|
|
257
|
+
syncDirectory(state.rootsDir);
|
|
258
|
+
}
|
|
259
|
+
prepareStateRoot(state);
|
|
260
|
+
fresh = true;
|
|
261
|
+
}
|
|
262
|
+
if (fresh) {
|
|
263
|
+
RunStore.open(state).close();
|
|
264
|
+
const db = new DatabaseSync(state.databasePath);
|
|
265
|
+
try {
|
|
266
|
+
db.exec(
|
|
267
|
+
"PRAGMA synchronous = FULL; BEGIN IMMEDIATE; CREATE TABLE IF NOT EXISTS cadence_package_version (version TEXT NOT NULL) STRICT",
|
|
268
|
+
);
|
|
269
|
+
const existing = db
|
|
270
|
+
.prepare("SELECT version FROM cadence_package_version")
|
|
271
|
+
.all();
|
|
272
|
+
if (existing.length === 0)
|
|
273
|
+
db.prepare("INSERT INTO cadence_package_version VALUES (?)").run(
|
|
274
|
+
version,
|
|
275
|
+
);
|
|
276
|
+
else if (existing.length !== 1 || existing[0].version !== version)
|
|
277
|
+
fail("package-state-reset-version-mismatch");
|
|
278
|
+
db.exec("COMMIT");
|
|
279
|
+
} finally {
|
|
280
|
+
db.close();
|
|
281
|
+
}
|
|
282
|
+
syncDirectory(state.rootDir);
|
|
283
|
+
}
|
|
284
|
+
if (intent) {
|
|
285
|
+
unlinkSync(intentFile);
|
|
286
|
+
syncDirectory(coordinator);
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
close,
|
|
290
|
+
...(intent
|
|
291
|
+
? {
|
|
292
|
+
notice: {
|
|
293
|
+
reason: intent.reason,
|
|
294
|
+
previousVersion: intent.previousVersion,
|
|
295
|
+
currentVersion: version,
|
|
296
|
+
backupPath: intent.backupPath,
|
|
297
|
+
oldRunsAbandoned: true,
|
|
298
|
+
workspaceRestored: false,
|
|
299
|
+
warning: RESET_WARNING,
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
: {}),
|
|
303
|
+
};
|
|
304
|
+
} catch (error) {
|
|
305
|
+
close();
|
|
306
|
+
if (error instanceof PackageStateError) throw error;
|
|
307
|
+
throw new PackageStateError("package-state-reset-unavailable");
|
|
308
|
+
}
|
|
309
|
+
}
|