@actiondock/core 2.0.12 → 2.2.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 +61 -30
- package/dist/app/app.d.ts +73 -0
- package/dist/app/app.js +700 -0
- package/dist/app/index.d.ts +2 -0
- package/dist/app/index.js +2 -0
- package/dist/app/types.d.ts +283 -0
- package/dist/app/types.js +1 -0
- package/dist/catalog/action-index.d.ts +1 -1
- package/dist/catalog/action-index.js +93 -0
- package/dist/catalog/action-resolver.d.ts +2 -2
- package/dist/catalog/action-resolver.js +95 -0
- package/dist/catalog/index.d.ts +5 -5
- package/dist/catalog/index.js +5 -0
- package/dist/catalog/location-registry.d.ts +5 -5
- package/dist/catalog/location-registry.js +138 -0
- package/dist/catalog/package-catalog.d.ts +2 -2
- package/dist/catalog/package-catalog.js +76 -0
- package/dist/catalog/types.d.ts +2 -2
- package/dist/catalog/types.js +1 -0
- package/dist/doctor/doctor.d.ts +2 -1
- package/dist/doctor/doctor.js +613 -0
- package/dist/doctor/index.d.ts +2 -2
- package/dist/doctor/index.js +2 -0
- package/dist/doctor/types.js +1 -0
- package/dist/errors.d.ts +122 -0
- package/dist/errors.js +123 -0
- package/dist/execution/index.d.ts +2 -2
- package/dist/execution/index.js +2 -0
- package/dist/execution/service.d.ts +46 -41
- package/dist/execution/service.js +633 -0
- package/dist/execution/types.d.ts +59 -2
- package/dist/execution/types.js +1 -0
- package/dist/export/index.d.ts +1 -1
- package/dist/export/index.js +1 -0
- package/dist/export/templates.d.ts +45 -7
- package/dist/export/templates.js +621 -0
- package/dist/filter/index.d.ts +1 -1
- package/dist/filter/index.js +1 -0
- package/dist/filter/intent.js +123 -0
- package/dist/host/host.d.ts +55 -0
- package/dist/host/host.js +740 -0
- package/dist/host/index.d.ts +2 -0
- package/dist/host/index.js +2 -0
- package/dist/host/types.d.ts +81 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +21 -16
- package/dist/index.js +20 -8458
- package/dist/ipc/diagnostic.d.ts +65 -0
- package/dist/ipc/diagnostic.js +129 -0
- package/dist/ipc/host.d.ts +7 -0
- package/dist/ipc/host.js +93 -0
- package/dist/ipc/index.d.ts +4 -0
- package/dist/ipc/index.js +4 -0
- package/dist/ipc/target.d.ts +56 -0
- package/dist/ipc/target.js +297 -0
- package/dist/ipc/types.d.ts +42 -0
- package/dist/ipc/types.js +1 -0
- package/dist/platform/default.d.ts +32 -0
- package/dist/platform/default.js +40 -0
- package/dist/platform/index.d.ts +3 -0
- package/dist/platform/index.js +3 -0
- package/dist/platform/node-fs.d.ts +37 -0
- package/dist/platform/node-fs.js +81 -0
- package/dist/platform/types.d.ts +104 -0
- package/dist/platform/types.js +1 -0
- package/dist/profile/client.d.ts +58 -12
- package/dist/profile/client.js +467 -0
- package/dist/profile/index.d.ts +3 -3
- package/dist/profile/index.js +3 -0
- package/dist/profile/manager.d.ts +2 -2
- package/dist/profile/manager.js +294 -0
- package/dist/profile/types.d.ts +1 -1
- package/dist/profile/types.js +1 -0
- package/dist/project/closure.js +68 -0
- package/dist/project/digest.d.ts +29 -0
- package/dist/project/digest.js +324 -0
- package/dist/project/index.d.ts +10 -5
- package/dist/project/index.js +10 -0
- package/dist/project/init.d.ts +3 -4
- package/dist/project/init.js +175 -0
- package/dist/project/loader.d.ts +21 -27
- package/dist/project/loader.js +484 -0
- package/dist/project/lockfile.d.ts +55 -0
- package/dist/project/lockfile.js +175 -0
- package/dist/project/manifest.d.ts +6 -28
- package/dist/project/manifest.js +220 -0
- package/dist/project/resolver.d.ts +116 -0
- package/dist/project/resolver.js +376 -0
- package/dist/project/transactions.d.ts +103 -0
- package/dist/project/transactions.js +1203 -0
- package/dist/project/types-generator.d.ts +33 -0
- package/dist/project/types-generator.js +168 -0
- package/dist/project/types.d.ts +58 -82
- package/dist/project/types.js +1 -0
- package/dist/registry/index.d.ts +5 -2
- package/dist/registry/index.js +5 -0
- package/dist/registry/lock.d.ts +27 -0
- package/dist/registry/lock.js +93 -0
- package/dist/registry/registry.d.ts +32 -11
- package/dist/registry/registry.js +611 -0
- package/dist/registry/resolve.d.ts +62 -0
- package/dist/registry/resolve.js +195 -0
- package/dist/registry/scan.d.ts +38 -0
- package/dist/registry/scan.js +135 -0
- package/dist/registry/types.d.ts +1 -1
- package/dist/registry/types.js +1 -0
- package/dist/runtime/action-collection.d.ts +27 -0
- package/dist/runtime/action-collection.js +57 -0
- package/dist/runtime/clock.d.ts +0 -2
- package/dist/runtime/clock.js +17 -0
- package/dist/runtime/context.d.ts +16 -5
- package/dist/runtime/context.js +242 -0
- package/dist/runtime/env.d.ts +3 -1
- package/dist/runtime/env.js +159 -0
- package/dist/runtime/events.d.ts +46 -14
- package/dist/runtime/events.js +604 -0
- package/dist/runtime/execution-manager.d.ts +1 -1
- package/dist/runtime/execution-manager.js +66 -0
- package/dist/runtime/index.d.ts +10 -9
- package/dist/runtime/index.js +10 -0
- package/dist/runtime/module-loader.d.ts +0 -8
- package/dist/runtime/module-loader.js +16 -0
- package/dist/runtime/process.d.ts +2 -4
- package/dist/runtime/process.js +146 -0
- package/dist/runtime/runner.d.ts +117 -10
- package/dist/runtime/runner.js +984 -0
- package/dist/runtime/standalone.d.ts +68 -19
- package/dist/runtime/standalone.js +536 -0
- package/dist/schema/validator.js +116 -0
- package/dist/server/body.js +93 -0
- package/dist/server/index.d.ts +5 -6
- package/dist/server/index.js +5 -0
- package/dist/server/routes/actions.d.ts +2 -2
- package/dist/server/routes/actions.js +298 -0
- package/dist/server/routes/common.d.ts +28 -17
- package/dist/server/routes/common.js +104 -0
- package/dist/server/routes/config.d.ts +2 -1
- package/dist/server/routes/config.js +173 -0
- package/dist/server/routes/doctor.d.ts +2 -2
- package/dist/server/routes/doctor.js +63 -0
- package/dist/server/routes/health.d.ts +2 -2
- package/dist/server/routes/health.js +35 -0
- package/dist/server/routes/index.d.ts +9 -9
- package/dist/server/routes/index.js +9 -0
- package/dist/server/routes/info.d.ts +4 -2
- package/dist/server/routes/info.js +158 -0
- package/dist/server/routes/playbooks.d.ts +2 -2
- package/dist/server/routes/playbooks.js +134 -0
- package/dist/server/routes/runs.d.ts +2 -2
- package/dist/server/routes/runs.js +343 -0
- package/dist/server/routes/state.d.ts +2 -1
- package/dist/server/routes/state.js +169 -0
- package/dist/server/security.d.ts +11 -4
- package/dist/server/security.js +101 -0
- package/dist/server/server.d.ts +6 -6
- package/dist/server/server.js +355 -0
- package/dist/server/types.d.ts +27 -8
- package/dist/server/types.js +1 -0
- package/dist/storage/data-dir-lock.d.ts +119 -0
- package/dist/storage/data-dir-lock.js +1016 -0
- package/dist/storage/driver.d.ts +4 -10
- package/dist/storage/driver.js +59 -0
- package/dist/storage/index.d.ts +30 -10
- package/dist/storage/index.js +98 -0
- package/dist/storage/lazy.d.ts +8 -0
- package/dist/storage/lazy.js +44 -0
- package/dist/storage/mask.d.ts +12 -5
- package/dist/storage/mask.js +48 -0
- package/dist/storage/params.d.ts +13 -0
- package/dist/storage/params.js +32 -0
- package/dist/storage/sqlite.d.ts +19 -6
- package/dist/storage/sqlite.js +689 -0
- package/dist/storage/types.d.ts +57 -6
- package/dist/storage/types.js +14 -0
- package/dist/target/index.d.ts +4 -0
- package/dist/target/index.js +4 -0
- package/dist/target/local.d.ts +52 -0
- package/dist/target/local.js +343 -0
- package/dist/target/remote.d.ts +76 -0
- package/dist/target/remote.js +839 -0
- package/dist/target/target.d.ts +6 -0
- package/dist/target/target.js +35 -0
- package/dist/target/types.d.ts +246 -0
- package/dist/target/types.js +34 -0
- package/dist/utils/index.d.ts +22 -2
- package/dist/utils/index.js +180 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +4 -0
- package/package.json +6 -7
- package/dist/build/index.d.ts +0 -1
- package/dist/build/templates.d.ts +0 -19
- package/dist/server/runtime-registry.d.ts +0 -78
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import fs, { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* 同步休眠指定毫秒数。
|
|
7
|
+
*/
|
|
8
|
+
function sleepSync(ms) {
|
|
9
|
+
try {
|
|
10
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
const end = Date.now() + ms;
|
|
14
|
+
while (Date.now() < end) {
|
|
15
|
+
// 降级忙等待
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 获取项目排他锁路径最近一次修改时间戳。
|
|
21
|
+
*/
|
|
22
|
+
function getProjectLockMtimeMs(lockPath, isDir) {
|
|
23
|
+
try {
|
|
24
|
+
const stat = statSync(lockPath);
|
|
25
|
+
let mtime = Math.max(stat.mtimeMs, stat.ctimeMs ?? 0);
|
|
26
|
+
if (isDir) {
|
|
27
|
+
const metaPath = join(lockPath, "metadata.json");
|
|
28
|
+
if (existsSync(metaPath)) {
|
|
29
|
+
const metaStat = statSync(metaPath);
|
|
30
|
+
mtime = Math.max(mtime, metaStat.mtimeMs, metaStat.ctimeMs ?? 0);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return mtime;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return Date.now();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 读取项目排他锁元数据并执行宽限期检测。
|
|
41
|
+
*/
|
|
42
|
+
function readProjectLockWithGracePeriod(lockPath, gracePeriodMs = 3000, deadline) {
|
|
43
|
+
if (!existsSync(lockPath)) {
|
|
44
|
+
return { exists: false, isDir: false, inGracePeriod: false };
|
|
45
|
+
}
|
|
46
|
+
let isDir = false;
|
|
47
|
+
try {
|
|
48
|
+
isDir = statSync(lockPath).isDirectory();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { exists: false, isDir: false, inGracePeriod: false };
|
|
52
|
+
}
|
|
53
|
+
const metaPath = isDir ? join(lockPath, "metadata.json") : lockPath;
|
|
54
|
+
const tryParse = () => {
|
|
55
|
+
try {
|
|
56
|
+
if (existsSync(metaPath)) {
|
|
57
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
58
|
+
if (raw.trim().length > 0) {
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (parsed && typeof parsed.pid === "number") {
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// 损坏或并发写入中
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
};
|
|
71
|
+
const initialInfo = tryParse();
|
|
72
|
+
if (initialInfo) {
|
|
73
|
+
return { exists: true, isDir, info: initialInfo, inGracePeriod: false };
|
|
74
|
+
}
|
|
75
|
+
const mtime = getProjectLockMtimeMs(lockPath, isDir);
|
|
76
|
+
const age = Date.now() - mtime;
|
|
77
|
+
if (age < gracePeriodMs) {
|
|
78
|
+
const maxRetries = 20;
|
|
79
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
80
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
const remaining = deadline !== undefined ? deadline - Date.now() : 50;
|
|
84
|
+
if (remaining <= 0) {
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
sleepSync(Math.min(50, remaining));
|
|
88
|
+
const retriedInfo = tryParse();
|
|
89
|
+
if (retriedInfo) {
|
|
90
|
+
return { exists: true, isDir, info: retriedInfo, inGracePeriod: false };
|
|
91
|
+
}
|
|
92
|
+
if (!existsSync(lockPath)) {
|
|
93
|
+
return { exists: false, isDir: false, inGracePeriod: false };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const currentAge = Date.now() - getProjectLockMtimeMs(lockPath, isDir);
|
|
97
|
+
if (currentAge < gracePeriodMs) {
|
|
98
|
+
return { exists: true, isDir, inGracePeriod: true };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { exists: true, isDir, inGracePeriod: false };
|
|
102
|
+
}
|
|
103
|
+
import { LOCKFILE_NAME } from "./lockfile.js";
|
|
104
|
+
import { MANIFEST_FILE_NAME } from "./manifest.js";
|
|
105
|
+
export const SNAPSHOT_TRACKED_FILES = [
|
|
106
|
+
"package.json",
|
|
107
|
+
MANIFEST_FILE_NAME,
|
|
108
|
+
LOCKFILE_NAME,
|
|
109
|
+
"package-lock.json",
|
|
110
|
+
"npm-shrinkwrap.json",
|
|
111
|
+
"bun.lock",
|
|
112
|
+
"bun.lockb",
|
|
113
|
+
"pnpm-lock.yaml",
|
|
114
|
+
"yarn.lock",
|
|
115
|
+
];
|
|
116
|
+
/**
|
|
117
|
+
* 检查当前进程 PID 是否处于活跃运行状态。
|
|
118
|
+
*/
|
|
119
|
+
export function isPidAlive(pid) {
|
|
120
|
+
if (typeof pid !== "number" || isNaN(pid) || pid <= 0) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
process.kill(pid, 0);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return err?.code === "EPERM";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* 检查接管守卫(reclaim guard)目录状态。
|
|
133
|
+
*
|
|
134
|
+
* - 若目录不存在:返回非活跃。
|
|
135
|
+
* - 若处于宽限期内或持有者存活:返回活跃,禁止抢先创建主锁。
|
|
136
|
+
* - 若持有者已死亡且超过宽限期:返回陈旧,可供安全清理。
|
|
137
|
+
*/
|
|
138
|
+
function checkProjectReclaimGuard(reclaimPath, gracePeriodMs = 1000) {
|
|
139
|
+
if (!existsSync(reclaimPath)) {
|
|
140
|
+
return { exists: false, active: false, isStale: false };
|
|
141
|
+
}
|
|
142
|
+
let isDir = false;
|
|
143
|
+
try {
|
|
144
|
+
isDir = statSync(reclaimPath).isDirectory();
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { exists: false, active: false, isStale: false };
|
|
148
|
+
}
|
|
149
|
+
const metaPath = isDir ? join(reclaimPath, "metadata.json") : reclaimPath;
|
|
150
|
+
const tryParse = () => {
|
|
151
|
+
try {
|
|
152
|
+
if (existsSync(metaPath)) {
|
|
153
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
154
|
+
if (raw.trim().length > 0) {
|
|
155
|
+
const parsed = JSON.parse(raw);
|
|
156
|
+
if (parsed && typeof parsed.pid === "number") {
|
|
157
|
+
return parsed;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch { }
|
|
163
|
+
return undefined;
|
|
164
|
+
};
|
|
165
|
+
const info = tryParse();
|
|
166
|
+
if (info && typeof info.pid === "number") {
|
|
167
|
+
const guardToken = typeof info.guardToken === "string" ? info.guardToken : undefined;
|
|
168
|
+
const createdAt = typeof info.createdAt === "number" ? info.createdAt : getProjectLockMtimeMs(reclaimPath, isDir);
|
|
169
|
+
const age = Date.now() - createdAt;
|
|
170
|
+
if (isPidAlive(info.pid)) {
|
|
171
|
+
return { exists: true, active: true, isStale: false, holderPid: info.pid, guardToken };
|
|
172
|
+
}
|
|
173
|
+
if (age < gracePeriodMs) {
|
|
174
|
+
return { exists: true, active: true, isStale: false, holderPid: info.pid, guardToken };
|
|
175
|
+
}
|
|
176
|
+
return { exists: true, active: false, isStale: true, holderPid: info.pid, guardToken };
|
|
177
|
+
}
|
|
178
|
+
const mtime = getProjectLockMtimeMs(reclaimPath, isDir);
|
|
179
|
+
const age = Date.now() - mtime;
|
|
180
|
+
if (age < gracePeriodMs) {
|
|
181
|
+
return { exists: true, active: true, isStale: false };
|
|
182
|
+
}
|
|
183
|
+
return { exists: true, active: false, isStale: true };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 尝试原子获取接管守卫(reclaim guard)。
|
|
187
|
+
* 基于 mkdirSync 原子创建目录并写入自身元数据与唯一 guardToken。
|
|
188
|
+
*/
|
|
189
|
+
function tryAcquireProjectReclaimGuard(reclaimPath, pid, guardToken) {
|
|
190
|
+
try {
|
|
191
|
+
mkdirSync(reclaimPath, { mode: 0o700 });
|
|
192
|
+
const metaPath = join(reclaimPath, "metadata.json");
|
|
193
|
+
const tmpPath = join(reclaimPath, `metadata.json.tmp.${pid}.${randomUUID().slice(0, 8)}`);
|
|
194
|
+
writeFileSync(tmpPath, JSON.stringify({ pid, guardToken, createdAt: Date.now() }, null, 2), "utf-8");
|
|
195
|
+
renameSync(tmpPath, metaPath);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* 安全清理陈旧接管守卫(reclaim guard)。
|
|
204
|
+
* 采用原子重命名检疫并严格核对 guardToken 与持有者存活状态,确保仅删除目标陈旧 guard,严禁误删活跃守卫或并发新守卫。
|
|
205
|
+
*
|
|
206
|
+
* @param reclaimPath 目标守卫路径
|
|
207
|
+
* @param expectedGuardToken 预期持有的守卫令牌(必填,拒绝未指定令牌的盲目清理)
|
|
208
|
+
*/
|
|
209
|
+
export function safeRemoveStaleProjectReclaimGuard(reclaimPath, expectedGuardToken) {
|
|
210
|
+
if (!expectedGuardToken || typeof expectedGuardToken !== "string")
|
|
211
|
+
return;
|
|
212
|
+
if (!existsSync(reclaimPath))
|
|
213
|
+
return;
|
|
214
|
+
let isDir = false;
|
|
215
|
+
try {
|
|
216
|
+
isDir = statSync(reclaimPath).isDirectory();
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const metaPath = isDir ? join(reclaimPath, "metadata.json") : reclaimPath;
|
|
222
|
+
if (!existsSync(metaPath)) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const metaStat = statSync(metaPath);
|
|
227
|
+
const mtimeMs = metaStat.mtimeMs;
|
|
228
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
229
|
+
if (raw.trim().length === 0) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const parsed = JSON.parse(raw);
|
|
233
|
+
if (parsed?.guardToken !== expectedGuardToken) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// 自身验证 fail-closed:
|
|
237
|
+
// 若不是当前进程自身清理自身守卫(即 parsed.pid !== process.pid):
|
|
238
|
+
// 1. 验证持有者 PID 是否存活,若仍存活则判定守卫仍处于活跃状态,严禁删除!
|
|
239
|
+
// 2. 验证是否处于创建宽限期内(1000ms),若在宽限期内严禁删除!
|
|
240
|
+
if (parsed?.pid !== process.pid) {
|
|
241
|
+
if (typeof parsed?.pid === "number" && isPidAlive(parsed.pid)) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (Date.now() - mtimeMs < 1000) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const quarantinePath = `${reclaimPath}.quarantine.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
|
|
253
|
+
try {
|
|
254
|
+
fs.renameSync(reclaimPath, quarantinePath);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const quarantinedMetaPath = isDir ? join(quarantinePath, "metadata.json") : quarantinePath;
|
|
261
|
+
let actualGuardToken;
|
|
262
|
+
let actualPid;
|
|
263
|
+
if (existsSync(quarantinedMetaPath)) {
|
|
264
|
+
try {
|
|
265
|
+
const raw = readFileSync(quarantinedMetaPath, "utf-8");
|
|
266
|
+
if (raw.trim().length > 0) {
|
|
267
|
+
const parsed = JSON.parse(raw);
|
|
268
|
+
actualGuardToken = parsed?.guardToken;
|
|
269
|
+
actualPid = typeof parsed?.pid === "number" ? parsed.pid : undefined;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch { }
|
|
273
|
+
}
|
|
274
|
+
// 后置校验:必须再次核对 actualGuardToken 与 expectedGuardToken 一致,
|
|
275
|
+
// 且若持有者非自身,必须确保 actualPid 确已死亡。
|
|
276
|
+
if (expectedGuardToken !== actualGuardToken ||
|
|
277
|
+
(actualPid !== undefined && actualPid !== process.pid && isPidAlive(actualPid))) {
|
|
278
|
+
// 并非此前检查的目标陈旧 guard(已被并发者替换或持有者存活)
|
|
279
|
+
// 保持隔离状态,转换为 .orphan 脱离态,脱离创建者 Host PID 的存活保护,由 GC 基于真实凭证与时间清理
|
|
280
|
+
const orphanPath = `${reclaimPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`;
|
|
281
|
+
try {
|
|
282
|
+
fs.renameSync(quarantinePath, orphanPath);
|
|
283
|
+
}
|
|
284
|
+
catch { }
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// 确认正是目标陈旧 guard(或自身持有的 guard),安全清理
|
|
288
|
+
fs.rmSync(quarantinePath, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// 发生异常,保持隔离状态,转换为 .orphan 脱离态
|
|
292
|
+
const orphanPath = `${reclaimPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`;
|
|
293
|
+
try {
|
|
294
|
+
fs.renameSync(quarantinePath, orphanPath);
|
|
295
|
+
}
|
|
296
|
+
catch { }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* 从工程锁隔离或回滚临时目录名称中解析操作者 PID 与隔离生成时间戳。
|
|
301
|
+
* 命名规范:*.(quarantine|rollback|release).<pid>.<timestamp>.<uuid> 或 *.orphan.<timestamp>.<uuid>
|
|
302
|
+
*/
|
|
303
|
+
export function parseProjectQuarantineTimestamp(entryName) {
|
|
304
|
+
const orphanMatch = entryName.match(/\.orphan\.(\d+)(?:\.|$)/);
|
|
305
|
+
if (orphanMatch && orphanMatch[1]) {
|
|
306
|
+
const ts = parseInt(orphanMatch[1], 10);
|
|
307
|
+
return {
|
|
308
|
+
timestamp: !Number.isNaN(ts) && ts > 0 ? ts : undefined,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const match = entryName.match(/\.(?:quarantine|rollback|release)\.(\d+)\.(\d+)(?:\.|$)/);
|
|
312
|
+
if (match && match[1] && match[2]) {
|
|
313
|
+
const pid = parseInt(match[1], 10);
|
|
314
|
+
const ts = parseInt(match[2], 10);
|
|
315
|
+
return {
|
|
316
|
+
operatorPid: !Number.isNaN(pid) && pid > 0 ? pid : undefined,
|
|
317
|
+
timestamp: !Number.isNaN(ts) && ts > 0 ? ts : undefined,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
const fallbackMatch = entryName.match(/\.(?:quarantine|rollback|release)\.(\d+)(?:\.|$)/);
|
|
321
|
+
if (fallbackMatch && fallbackMatch[1]) {
|
|
322
|
+
const ts = parseInt(fallbackMatch[1], 10);
|
|
323
|
+
return {
|
|
324
|
+
timestamp: !Number.isNaN(ts) && ts > 0 ? ts : undefined,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return {};
|
|
328
|
+
}
|
|
329
|
+
export const parseQuarantineTimestamp = parseProjectQuarantineTimestamp;
|
|
330
|
+
/**
|
|
331
|
+
* 读取目标规范路径(工程主锁目录或 reclaim 目录)中记录的当前有效令牌凭据。
|
|
332
|
+
*/
|
|
333
|
+
function readCanonicalProjectToken(targetPath) {
|
|
334
|
+
try {
|
|
335
|
+
if (!existsSync(targetPath)) {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
let isDir = false;
|
|
339
|
+
try {
|
|
340
|
+
isDir = statSync(targetPath).isDirectory();
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
const metaPath = isDir ? join(targetPath, "metadata.json") : targetPath;
|
|
346
|
+
if (!existsSync(metaPath)) {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
350
|
+
if (raw.trim().length === 0) {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
const parsed = JSON.parse(raw);
|
|
354
|
+
if (typeof parsed?.guardToken === "string" && parsed.guardToken.length > 0) {
|
|
355
|
+
return parsed.guardToken;
|
|
356
|
+
}
|
|
357
|
+
if (typeof parsed?.lockToken === "string" && parsed.lockToken.length > 0) {
|
|
358
|
+
return parsed.lockToken;
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* 清理过期的工程锁隔离目录(GC 回收机制)。
|
|
368
|
+
* 实施分流存活校验,防止误删活跃持锁者与回滚中目录,同时防止孤儿目录无限泄漏:
|
|
369
|
+
* - 共同前置条件:存活年龄必须超过 maxAgeMs(默认 10000ms),未超期前严禁清理(保持宽限期)。
|
|
370
|
+
* - 活跃隔离目录(quarantine / rollback / release):若 operatorPid 存活或内部 metadata.json 记录的 pid 与 childPids 存活,跳过不予清理。
|
|
371
|
+
* - 孤儿脱离目录(orphan):内部旧 metadata.pid 不再作为续命依据;提取其内部 orphanToken,仅当当前规范主路径(主锁目录与 reclaim 目录)依然持有该凭证时保守跳过;规范路径不存在或已变更凭据时直接安全清理。
|
|
372
|
+
*/
|
|
373
|
+
export function cleanStaleProjectQuarantines(parentDir, basePrefix, maxAgeMs = 10000, deadline) {
|
|
374
|
+
try {
|
|
375
|
+
if (!existsSync(parentDir))
|
|
376
|
+
return;
|
|
377
|
+
const entries = readdirSync(parentDir);
|
|
378
|
+
const now = Date.now();
|
|
379
|
+
const canonicalPrimaryLockPath = basePrefix.endsWith(".reclaim")
|
|
380
|
+
? join(parentDir, basePrefix.slice(0, -".reclaim".length))
|
|
381
|
+
: join(parentDir, basePrefix);
|
|
382
|
+
const canonicalReclaimPath = `${canonicalPrimaryLockPath}.reclaim`;
|
|
383
|
+
for (const entry of entries) {
|
|
384
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
if (!entry.startsWith(basePrefix))
|
|
388
|
+
continue;
|
|
389
|
+
if (!entry.includes(".quarantine.") &&
|
|
390
|
+
!entry.includes(".rollback.") &&
|
|
391
|
+
!entry.includes(".release.") &&
|
|
392
|
+
!entry.includes(".orphan.")) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const fullPath = join(parentDir, entry);
|
|
396
|
+
try {
|
|
397
|
+
const { operatorPid, timestamp } = parseProjectQuarantineTimestamp(entry);
|
|
398
|
+
// a. 共同前置条件:存活年龄必须超过 maxAgeMs(默认 10000ms),未超期前严禁清理(保持宽限期)
|
|
399
|
+
let age;
|
|
400
|
+
if (timestamp !== undefined) {
|
|
401
|
+
age = now - timestamp;
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
const stat = statSync(fullPath);
|
|
405
|
+
age = now - stat.mtimeMs;
|
|
406
|
+
}
|
|
407
|
+
if (age <= maxAgeMs) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const isOrphan = entry.includes(".orphan.");
|
|
411
|
+
if (!isOrphan) {
|
|
412
|
+
// b. 若不是 .orphan.(即活跃的 quarantine / rollback / release 目录):
|
|
413
|
+
// - 若 operatorPid 存活,continue;
|
|
414
|
+
if (operatorPid !== undefined && isPidAlive(operatorPid)) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
// - 若内部 metadata.json 记录的 pid 或 childPids 存活,continue;
|
|
418
|
+
let isDir = false;
|
|
419
|
+
try {
|
|
420
|
+
isDir = statSync(fullPath).isDirectory();
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const metaPath = isDir ? join(fullPath, "metadata.json") : fullPath;
|
|
426
|
+
if (existsSync(metaPath)) {
|
|
427
|
+
try {
|
|
428
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
429
|
+
if (raw.trim().length > 0) {
|
|
430
|
+
const meta = JSON.parse(raw);
|
|
431
|
+
if (typeof meta?.pid === "number" && isPidAlive(meta.pid)) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (Array.isArray(meta?.childPids) &&
|
|
435
|
+
meta.childPids.some((childPid) => typeof childPid === "number" && isPidAlive(childPid))) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// 元数据损坏或不可读,不视为存在存活持有者
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
rmSync(fullPath, { recursive: true, force: true });
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
// c. 若是 .orphan. 目录(已脱离规范路径):
|
|
448
|
+
// 严禁再用内部旧 metadata.pid 存活为由无限续命!
|
|
449
|
+
// 提取 orphan 内部记录的 orphanToken(guardToken ?? lockToken)
|
|
450
|
+
const orphanToken = readCanonicalProjectToken(fullPath);
|
|
451
|
+
// 核对当前 canonical 规范主路径(主锁目录与 reclaim 目录)的当前凭证:
|
|
452
|
+
// 仅当当前规范路径依然持有该 orphanToken 时保守跳过;若规范路径不存在或已切换为其他 token,直接执行 rmSync 安全清理!
|
|
453
|
+
const primaryToken = readCanonicalProjectToken(canonicalPrimaryLockPath);
|
|
454
|
+
const reclaimToken = readCanonicalProjectToken(canonicalReclaimPath);
|
|
455
|
+
if (orphanToken !== undefined &&
|
|
456
|
+
(orphanToken === primaryToken || orphanToken === reclaimToken)) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
rmSync(fullPath, { recursive: true, force: true });
|
|
460
|
+
}
|
|
461
|
+
catch { }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
catch { }
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* 安全回滚当前进程创建的工程修改锁。
|
|
468
|
+
* 优先核对 lockToken,回退核对 sessionToken,防止误删接管者或并发新锁。
|
|
469
|
+
*/
|
|
470
|
+
export function safeRollbackProjectLock(lockPath, expectedSessionToken, expectedLockToken) {
|
|
471
|
+
if (!existsSync(lockPath))
|
|
472
|
+
return;
|
|
473
|
+
const quarantinePath = `${lockPath}.rollback.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
|
|
474
|
+
try {
|
|
475
|
+
renameSync(lockPath, quarantinePath);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
let isDir = false;
|
|
482
|
+
try {
|
|
483
|
+
isDir = statSync(quarantinePath).isDirectory();
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
isDir = false;
|
|
487
|
+
}
|
|
488
|
+
const metaPath = isDir ? join(quarantinePath, "metadata.json") : quarantinePath;
|
|
489
|
+
let actualSessionToken;
|
|
490
|
+
let actualLockToken;
|
|
491
|
+
if (existsSync(metaPath)) {
|
|
492
|
+
try {
|
|
493
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
494
|
+
const parsed = JSON.parse(raw);
|
|
495
|
+
actualSessionToken = parsed?.sessionToken;
|
|
496
|
+
actualLockToken = parsed?.lockToken;
|
|
497
|
+
}
|
|
498
|
+
catch { }
|
|
499
|
+
}
|
|
500
|
+
const isMatch = expectedLockToken
|
|
501
|
+
? (actualLockToken === expectedLockToken && actualSessionToken === expectedSessionToken)
|
|
502
|
+
: (actualSessionToken === expectedSessionToken);
|
|
503
|
+
if (!isMatch) {
|
|
504
|
+
// 并非自身刚才创建的锁目录,立即恢复原位!
|
|
505
|
+
try {
|
|
506
|
+
renameSync(quarantinePath, lockPath);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
try {
|
|
510
|
+
renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
|
|
511
|
+
}
|
|
512
|
+
catch { }
|
|
513
|
+
}
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
rmSync(quarantinePath, { recursive: true, force: true });
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
try {
|
|
520
|
+
renameSync(quarantinePath, lockPath);
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
try {
|
|
524
|
+
renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
|
|
525
|
+
}
|
|
526
|
+
catch { }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* 安全隔离并清理陈旧工程主锁。
|
|
532
|
+
* 隔离后复核元数据,确保仅删除目标陈旧锁,若已被更新或仍有存活所有者则恢复原位。
|
|
533
|
+
*/
|
|
534
|
+
function safeQuarantineStaleProjectLock(lockPath, expectedSessionToken, expectedLockToken) {
|
|
535
|
+
if (!existsSync(lockPath))
|
|
536
|
+
return true;
|
|
537
|
+
const quarantinePath = `${lockPath}.quarantine.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
|
|
538
|
+
try {
|
|
539
|
+
renameSync(lockPath, quarantinePath);
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
let isDir = false;
|
|
546
|
+
try {
|
|
547
|
+
isDir = statSync(quarantinePath).isDirectory();
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
isDir = false;
|
|
551
|
+
}
|
|
552
|
+
const metaPath = isDir ? join(quarantinePath, "metadata.json") : quarantinePath;
|
|
553
|
+
let actualSessionToken;
|
|
554
|
+
let actualLockToken;
|
|
555
|
+
let actualPid;
|
|
556
|
+
if (existsSync(metaPath)) {
|
|
557
|
+
try {
|
|
558
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
559
|
+
const parsed = JSON.parse(raw);
|
|
560
|
+
actualSessionToken = parsed?.sessionToken;
|
|
561
|
+
actualLockToken = parsed?.lockToken;
|
|
562
|
+
actualPid = parsed?.pid;
|
|
563
|
+
}
|
|
564
|
+
catch { }
|
|
565
|
+
}
|
|
566
|
+
const tokenMismatch = expectedLockToken
|
|
567
|
+
? (actualLockToken !== expectedLockToken || (expectedSessionToken && actualSessionToken !== expectedSessionToken))
|
|
568
|
+
: ((expectedSessionToken && actualSessionToken !== expectedSessionToken) || (!expectedSessionToken && actualSessionToken));
|
|
569
|
+
if (tokenMismatch) {
|
|
570
|
+
// 锁已被其他竞争者接管并写入新 token,绝不可删除!立即恢复原位
|
|
571
|
+
try {
|
|
572
|
+
renameSync(quarantinePath, lockPath);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
try {
|
|
576
|
+
renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
|
|
577
|
+
}
|
|
578
|
+
catch { }
|
|
579
|
+
}
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
if (typeof actualPid === "number" && isPidAlive(actualPid)) {
|
|
583
|
+
// 持有者实际仍存活,恢复原位
|
|
584
|
+
try {
|
|
585
|
+
renameSync(quarantinePath, lockPath);
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
try {
|
|
589
|
+
renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
|
|
590
|
+
}
|
|
591
|
+
catch { }
|
|
592
|
+
}
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
rmSync(quarantinePath, { recursive: true, force: true });
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
try {
|
|
600
|
+
renameSync(quarantinePath, lockPath);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
try {
|
|
604
|
+
renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
|
|
605
|
+
}
|
|
606
|
+
catch { }
|
|
607
|
+
}
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* 安全释放工程主锁。
|
|
613
|
+
* 仅当锁目录中持有当前 lockToken / sessionToken 时才删除,杜绝删除他人新锁。
|
|
614
|
+
*/
|
|
615
|
+
export function safeReleaseProjectLock(lockPath, expectedSessionToken, expectedLockToken) {
|
|
616
|
+
if (!existsSync(lockPath))
|
|
617
|
+
return;
|
|
618
|
+
const quarantinePath = `${lockPath}.release.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
|
|
619
|
+
try {
|
|
620
|
+
renameSync(lockPath, quarantinePath);
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
let isDir = false;
|
|
627
|
+
try {
|
|
628
|
+
isDir = statSync(quarantinePath).isDirectory();
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
isDir = false;
|
|
632
|
+
}
|
|
633
|
+
const metaPath = isDir ? join(quarantinePath, "metadata.json") : quarantinePath;
|
|
634
|
+
let actualSessionToken;
|
|
635
|
+
let actualLockToken;
|
|
636
|
+
if (existsSync(metaPath)) {
|
|
637
|
+
try {
|
|
638
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
639
|
+
const parsed = JSON.parse(raw);
|
|
640
|
+
actualSessionToken = parsed?.sessionToken;
|
|
641
|
+
actualLockToken = parsed?.lockToken;
|
|
642
|
+
}
|
|
643
|
+
catch { }
|
|
644
|
+
}
|
|
645
|
+
const isMatch = expectedLockToken
|
|
646
|
+
? (actualLockToken === expectedLockToken && actualSessionToken === expectedSessionToken)
|
|
647
|
+
: (actualSessionToken === expectedSessionToken);
|
|
648
|
+
if (isMatch) {
|
|
649
|
+
rmSync(quarantinePath, { recursive: true, force: true });
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
// 并非当前会话持有的锁,恢复原位
|
|
653
|
+
try {
|
|
654
|
+
renameSync(quarantinePath, lockPath);
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
try {
|
|
658
|
+
renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
|
|
659
|
+
}
|
|
660
|
+
catch { }
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
try {
|
|
666
|
+
renameSync(quarantinePath, lockPath);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
try {
|
|
670
|
+
renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
|
|
671
|
+
}
|
|
672
|
+
catch { }
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
cleanStaleProjectQuarantines(dirname(lockPath), "project.lock");
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* 检查项目修改锁 .actiondock/project.lock 是否存在且持有者 PID 处于存活状态。
|
|
679
|
+
*
|
|
680
|
+
* @param projectRoot 项目根目录
|
|
681
|
+
* @param excludeSelf 是否排除当前进程本身(默认为 true,即仅当其他活跃进程持锁时判定为锁被占用)
|
|
682
|
+
*/
|
|
683
|
+
export function isProjectLockHeld(projectRoot, excludeSelf = true) {
|
|
684
|
+
const lockPath = join(projectRoot, ".actiondock", "project.lock");
|
|
685
|
+
const reclaimPath = `${lockPath}.reclaim`;
|
|
686
|
+
if (existsSync(reclaimPath)) {
|
|
687
|
+
const reclaimState = checkProjectReclaimGuard(reclaimPath, 1000);
|
|
688
|
+
if (reclaimState.active) {
|
|
689
|
+
if (!excludeSelf || reclaimState.holderPid !== process.pid) {
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (!existsSync(lockPath)) {
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
const lockState = readProjectLockWithGracePeriod(lockPath, 3000);
|
|
699
|
+
if (!lockState.exists) {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
if (lockState.inGracePeriod) {
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
if (lockState.info && typeof lockState.info.pid === "number") {
|
|
706
|
+
if (excludeSelf && lockState.info.pid === process.pid) {
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
return isPidAlive(lockState.info.pid);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
// 忽略读取解析异常
|
|
714
|
+
}
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* 获取工程修改排他锁(.actiondock/project.lock)。
|
|
719
|
+
* 引入所有竞争者均遵守的原子 reclaim guard 机制(project.lock.reclaim)。
|
|
720
|
+
* 采用 mkdirSync 原子排他目录创建、renameSync 元数据写入、宽限期重试检测与基于 sessionToken 的释放函数。
|
|
721
|
+
* 避免空文件窗口与 truncate。
|
|
722
|
+
*
|
|
723
|
+
* @param projectRoot 项目根目录
|
|
724
|
+
* @param options 锁配置参数
|
|
725
|
+
*/
|
|
726
|
+
export function acquireProjectLock(projectRoot, options = {}) {
|
|
727
|
+
const timeoutMs = options.acquireTimeoutMs ?? 5000;
|
|
728
|
+
const deadline = Date.now() + timeoutMs;
|
|
729
|
+
const metaDir = join(projectRoot, ".actiondock");
|
|
730
|
+
if (!existsSync(metaDir)) {
|
|
731
|
+
mkdirSync(metaDir, { recursive: true });
|
|
732
|
+
}
|
|
733
|
+
cleanStaleProjectQuarantines(metaDir, "project.lock", 10000, deadline);
|
|
734
|
+
const lockPath = join(metaDir, "project.lock");
|
|
735
|
+
const reclaimPath = `${lockPath}.reclaim`;
|
|
736
|
+
const sessionToken = options.sessionToken || randomUUID();
|
|
737
|
+
const lockToken = randomUUID();
|
|
738
|
+
const currentPid = process.pid;
|
|
739
|
+
const lockData = {
|
|
740
|
+
pid: currentPid,
|
|
741
|
+
sessionToken,
|
|
742
|
+
lockToken,
|
|
743
|
+
createdAt: Date.now(),
|
|
744
|
+
};
|
|
745
|
+
const content = JSON.stringify(lockData, null, 2);
|
|
746
|
+
while (true) {
|
|
747
|
+
// 1. 当竞争者尝试创建新主锁时,若检测到 reclaim 目录存在且处于宽限期内或持有者存活,必须等待,禁止在他人正在接管/验证期间抢先创建主锁
|
|
748
|
+
const reclaimState = checkProjectReclaimGuard(reclaimPath, 1000);
|
|
749
|
+
if (reclaimState.active) {
|
|
750
|
+
if (Date.now() >= deadline) {
|
|
751
|
+
const busyErr = new Error(`PROJECT_BUSY: Timeout waiting for active reclaim guard (PID ${reclaimState.holderPid ?? "unknown"}) on project '${projectRoot}' after ${timeoutMs}ms`);
|
|
752
|
+
busyErr.code = "PROJECT_BUSY";
|
|
753
|
+
throw busyErr;
|
|
754
|
+
}
|
|
755
|
+
sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (reclaimState.isStale && reclaimState.guardToken) {
|
|
759
|
+
// 若 reclaim guard 持有者意外崩溃,其他竞争者核对此前检查的 guardToken 安全清理
|
|
760
|
+
safeRemoveStaleProjectReclaimGuard(reclaimPath, reclaimState.guardToken);
|
|
761
|
+
}
|
|
762
|
+
// 2. 尝试常规获取新主锁
|
|
763
|
+
try {
|
|
764
|
+
fs.mkdirSync(lockPath, { mode: 0o700 });
|
|
765
|
+
const metaFile = join(lockPath, "metadata.json");
|
|
766
|
+
const tmpFile = join(lockPath, `metadata.json.tmp.${currentPid}.${randomUUID().slice(0, 8)}`);
|
|
767
|
+
writeFileSync(tmpFile, content, "utf-8");
|
|
768
|
+
renameSync(tmpFile, metaFile);
|
|
769
|
+
// 再次确认在此窗口期内是否有他人持有活跃 reclaim guard
|
|
770
|
+
const postCheck = checkProjectReclaimGuard(reclaimPath, 1000);
|
|
771
|
+
if (postCheck.active && postCheck.holderPid !== currentPid) {
|
|
772
|
+
// 仅当锁目录中包含自身创建的 sessionToken 与 lockToken 时安全回滚,杜绝误删他人新锁
|
|
773
|
+
safeRollbackProjectLock(lockPath, sessionToken, lockToken);
|
|
774
|
+
if (Date.now() >= deadline) {
|
|
775
|
+
const busyErr = new Error(`PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' due to active reclaim guard (PID ${postCheck.holderPid}) after ${timeoutMs}ms`);
|
|
776
|
+
busyErr.code = "PROJECT_BUSY";
|
|
777
|
+
throw busyErr;
|
|
778
|
+
}
|
|
779
|
+
sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
if (err && (err.code === "EEXIST" || err.code === "ENOENT")) {
|
|
786
|
+
const lockState = readProjectLockWithGracePeriod(lockPath, 3000, deadline);
|
|
787
|
+
if (!lockState.exists) {
|
|
788
|
+
if (Date.now() >= deadline) {
|
|
789
|
+
const busyErr = new Error(`PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' after ${timeoutMs}ms`);
|
|
790
|
+
busyErr.code = "PROJECT_BUSY";
|
|
791
|
+
throw busyErr;
|
|
792
|
+
}
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (lockState.inGracePeriod) {
|
|
796
|
+
if (Date.now() >= deadline) {
|
|
797
|
+
const busyErr = new Error(`PROJECT_BUSY: Timeout waiting for project lock grace period on '${projectRoot}' after ${timeoutMs}ms`);
|
|
798
|
+
busyErr.code = "PROJECT_BUSY";
|
|
799
|
+
throw busyErr;
|
|
800
|
+
}
|
|
801
|
+
sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (lockState.info && typeof lockState.info.pid === "number") {
|
|
805
|
+
if (isPidAlive(lockState.info.pid)) {
|
|
806
|
+
const busyErr = new Error(`PROJECT_BUSY: Project modification lock is held by PID ${lockState.info.pid}. Another command is running in ${projectRoot}.`);
|
|
807
|
+
busyErr.code = "PROJECT_BUSY";
|
|
808
|
+
throw busyErr;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
const staleSessionToken = lockState.info?.sessionToken;
|
|
812
|
+
const staleLockToken = lockState.info?.lockToken;
|
|
813
|
+
const guardToken = randomUUID();
|
|
814
|
+
// 当识别到主锁为陈旧锁时,竞争者必须先原子竞争获取 reclaim guard
|
|
815
|
+
const acquiredReclaim = tryAcquireProjectReclaimGuard(reclaimPath, currentPid, guardToken);
|
|
816
|
+
if (!acquiredReclaim) {
|
|
817
|
+
const currentReclaim = checkProjectReclaimGuard(reclaimPath, 1000);
|
|
818
|
+
if (currentReclaim.isStale && currentReclaim.guardToken) {
|
|
819
|
+
safeRemoveStaleProjectReclaimGuard(reclaimPath, currentReclaim.guardToken);
|
|
820
|
+
}
|
|
821
|
+
if (Date.now() >= deadline) {
|
|
822
|
+
const busyErr = new Error(`PROJECT_BUSY: Timeout contending for project reclaim guard on '${projectRoot}' after ${timeoutMs}ms`);
|
|
823
|
+
busyErr.code = "PROJECT_BUSY";
|
|
824
|
+
throw busyErr;
|
|
825
|
+
}
|
|
826
|
+
sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
// 仅成功获取 reclaim guard 的唯一胜利者获准执行:
|
|
830
|
+
// 复核主锁陈旧性 -> 隔离/清理陈旧锁 -> 原子创建新主锁并写入自身元数据 -> 清理 reclaim guard
|
|
831
|
+
try {
|
|
832
|
+
const verifyProjectReclaimOwnership = () => {
|
|
833
|
+
const ownGuard = checkProjectReclaimGuard(reclaimPath, 1000);
|
|
834
|
+
return Boolean(ownGuard.active &&
|
|
835
|
+
ownGuard.holderPid === currentPid &&
|
|
836
|
+
ownGuard.guardToken === guardToken);
|
|
837
|
+
};
|
|
838
|
+
// 自检校验自身守卫:验证自身 guardToken 依然有效且持有者为自身 PID
|
|
839
|
+
// 若已被抢占或不匹配,立即退出当前接管并 continue 重试,杜绝在守卫已失窃的情况下操作主锁
|
|
840
|
+
if (!verifyProjectReclaimOwnership()) {
|
|
841
|
+
if (Date.now() >= deadline) {
|
|
842
|
+
const busyErr = new Error(`PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' after ${timeoutMs}ms`);
|
|
843
|
+
busyErr.code = "PROJECT_BUSY";
|
|
844
|
+
throw busyErr;
|
|
845
|
+
}
|
|
846
|
+
sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
// 1. 复核主锁陈旧性
|
|
850
|
+
const recheckState = readProjectLockWithGracePeriod(lockPath, 1000, deadline);
|
|
851
|
+
if (recheckState.exists) {
|
|
852
|
+
if (recheckState.inGracePeriod) {
|
|
853
|
+
if (Date.now() >= deadline) {
|
|
854
|
+
const busyErr = new Error(`PROJECT_BUSY: Timeout waiting for project lock grace period on '${projectRoot}' after ${timeoutMs}ms`);
|
|
855
|
+
busyErr.code = "PROJECT_BUSY";
|
|
856
|
+
throw busyErr;
|
|
857
|
+
}
|
|
858
|
+
sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
if (recheckState.info &&
|
|
862
|
+
typeof recheckState.info.pid === "number" &&
|
|
863
|
+
isPidAlive(recheckState.info.pid)) {
|
|
864
|
+
const busyErr = new Error(`PROJECT_BUSY: Project modification lock is held by PID ${recheckState.info.pid}. Another command is running in ${projectRoot}.`);
|
|
865
|
+
busyErr.code = "PROJECT_BUSY";
|
|
866
|
+
throw busyErr;
|
|
867
|
+
}
|
|
868
|
+
// 2. 隔离并核验清理陈旧主锁(验证 sessionToken 一致,若已被他人占用则恢复原位)
|
|
869
|
+
const cleaned = safeQuarantineStaleProjectLock(lockPath, recheckState.info?.sessionToken ?? staleSessionToken, recheckState.info?.lockToken ?? staleLockToken);
|
|
870
|
+
if (!cleaned) {
|
|
871
|
+
if (Date.now() >= deadline) {
|
|
872
|
+
const busyErr = new Error(`PROJECT_BUSY: Timeout reclaiming stale project lock on '${projectRoot}' after ${timeoutMs}ms`);
|
|
873
|
+
busyErr.code = "PROJECT_BUSY";
|
|
874
|
+
throw busyErr;
|
|
875
|
+
}
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
// 后置复核 1:在清理陈旧主锁之后、创建新主锁之前,再次核验 reclaim guard 所有权
|
|
880
|
+
// 若守卫在此期间失窃,严禁继续创建新主锁,立即退出重试
|
|
881
|
+
if (!verifyProjectReclaimOwnership()) {
|
|
882
|
+
if (Date.now() >= deadline) {
|
|
883
|
+
const busyErr = new Error(`PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' after ${timeoutMs}ms`);
|
|
884
|
+
busyErr.code = "PROJECT_BUSY";
|
|
885
|
+
throw busyErr;
|
|
886
|
+
}
|
|
887
|
+
sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
// 3. 原子创建新主锁并写入自身元数据(严格遵守 deadline,并在每次重试检查时间窗口)
|
|
891
|
+
let created = false;
|
|
892
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
893
|
+
try {
|
|
894
|
+
fs.mkdirSync(lockPath, { mode: 0o700 });
|
|
895
|
+
created = true;
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
catch (createErr) {
|
|
899
|
+
if (createErr?.code === "EEXIST") {
|
|
900
|
+
if (Date.now() >= deadline) {
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
const remaining = deadline - Date.now();
|
|
904
|
+
if (remaining <= 0) {
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
sleepSync(Math.min(25, remaining));
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
throw createErr;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (!created) {
|
|
914
|
+
if (Date.now() >= deadline) {
|
|
915
|
+
const busyErr = new Error(`PROJECT_BUSY: Timeout creating project lock on '${projectRoot}' after ${timeoutMs}ms`);
|
|
916
|
+
busyErr.code = "PROJECT_BUSY";
|
|
917
|
+
throw busyErr;
|
|
918
|
+
}
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const metaFile = join(lockPath, "metadata.json");
|
|
922
|
+
const tmpFile = join(lockPath, `metadata.json.tmp.${currentPid}.${randomUUID().slice(0, 8)}`);
|
|
923
|
+
writeFileSync(tmpFile, content, "utf-8");
|
|
924
|
+
renameSync(tmpFile, metaFile);
|
|
925
|
+
// 后置复核 2:新主锁创建完成后、返回之前再次核验 reclaim guard 所有权
|
|
926
|
+
// 若守卫在创建新主锁期间失窃,说明存在并发仲裁漂移,严禁生效并安全回滚自身主锁
|
|
927
|
+
if (!verifyProjectReclaimOwnership()) {
|
|
928
|
+
safeRollbackProjectLock(lockPath, sessionToken, lockToken);
|
|
929
|
+
if (Date.now() >= deadline) {
|
|
930
|
+
const busyErr = new Error(`PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' after ${timeoutMs}ms`);
|
|
931
|
+
busyErr.code = "PROJECT_BUSY";
|
|
932
|
+
throw busyErr;
|
|
933
|
+
}
|
|
934
|
+
sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
break;
|
|
938
|
+
}
|
|
939
|
+
finally {
|
|
940
|
+
// 4. 清理 reclaim guard(必须核对自身 guardToken)
|
|
941
|
+
safeRemoveStaleProjectReclaimGuard(reclaimPath, guardToken);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
throw err;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
let released = false;
|
|
948
|
+
return () => {
|
|
949
|
+
if (released)
|
|
950
|
+
return;
|
|
951
|
+
released = true;
|
|
952
|
+
safeReleaseProjectLock(lockPath, sessionToken, lockToken);
|
|
953
|
+
cleanStaleProjectQuarantines(metaDir, "project.lock");
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* 依据恢复后的锁文件执行禁用安装脚本的冻结安装,使 node_modules 与声明重新一致。
|
|
958
|
+
*/
|
|
959
|
+
export function runFrozenInstall(projectRoot) {
|
|
960
|
+
if (process.env.ACTIONDOCK_AUTO_INSTALL === "false") {
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const pkgJsonPath = join(projectRoot, "package.json");
|
|
964
|
+
if (!existsSync(pkgJsonPath)) {
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const hasBunLock = existsSync(join(projectRoot, "bun.lock")) || existsSync(join(projectRoot, "bun.lockb"));
|
|
968
|
+
const hasNpmLock = existsSync(join(projectRoot, "package-lock.json")) || existsSync(join(projectRoot, "npm-shrinkwrap.json"));
|
|
969
|
+
let cmd = "npm";
|
|
970
|
+
let args = ["install", "--ignore-scripts"];
|
|
971
|
+
if (hasBunLock) {
|
|
972
|
+
cmd = "bun";
|
|
973
|
+
args = ["install", "--frozen-lockfile", "--ignore-scripts"];
|
|
974
|
+
}
|
|
975
|
+
else if (hasNpmLock) {
|
|
976
|
+
cmd = "npm";
|
|
977
|
+
args = ["ci", "--ignore-scripts"];
|
|
978
|
+
}
|
|
979
|
+
try {
|
|
980
|
+
const check = spawnSync(cmd, ["--version"], {
|
|
981
|
+
stdio: "pipe",
|
|
982
|
+
// Windows 兼容:npm 为 .cmd 脚本,无 shell 直接 spawn 必然 ENOENT,
|
|
983
|
+
// 会误判 npm 缺失而把冻结安装降级为普通 install
|
|
984
|
+
shell: process.platform === "win32",
|
|
985
|
+
});
|
|
986
|
+
if (check.status !== 0) {
|
|
987
|
+
cmd = "npm";
|
|
988
|
+
args = ["install", "--ignore-scripts"];
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
cmd = "npm";
|
|
993
|
+
args = ["install", "--ignore-scripts"];
|
|
994
|
+
}
|
|
995
|
+
const proc = spawnSync(cmd, args, {
|
|
996
|
+
cwd: projectRoot,
|
|
997
|
+
stdio: "pipe",
|
|
998
|
+
shell: process.platform === "win32",
|
|
999
|
+
});
|
|
1000
|
+
if (proc.status !== 0) {
|
|
1001
|
+
const errorMsg = proc.stderr?.toString() || proc.stdout?.toString() || "Unknown error";
|
|
1002
|
+
const err = new Error(`PROJECT_RECOVERY_REQUIRED: Frozen install failed during recovery in ${projectRoot}: ${errorMsg}`);
|
|
1003
|
+
err.code = "PROJECT_RECOVERY_REQUIRED";
|
|
1004
|
+
throw err;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* 检查项目是否存在未完成提交的悬空事务。
|
|
1009
|
+
*/
|
|
1010
|
+
export function hasPendingTransactions(projectRoot) {
|
|
1011
|
+
if (isProjectLockHeld(projectRoot)) {
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
const txBaseDir = join(projectRoot, ".actiondock", "transactions");
|
|
1015
|
+
if (!existsSync(txBaseDir)) {
|
|
1016
|
+
return false;
|
|
1017
|
+
}
|
|
1018
|
+
try {
|
|
1019
|
+
const entries = readdirSync(txBaseDir);
|
|
1020
|
+
for (const entry of entries) {
|
|
1021
|
+
const metaPath = join(txBaseDir, entry, "transaction.json");
|
|
1022
|
+
if (existsSync(metaPath)) {
|
|
1023
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
1024
|
+
const meta = JSON.parse(raw);
|
|
1025
|
+
if (meta.status === "pending") {
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
catch {
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* 启动新的工程修改事务快照。
|
|
1038
|
+
* 捕获 package.json、actiondock.json、actiondock.lock.json 及各包管理器锁文件当前快照。
|
|
1039
|
+
*/
|
|
1040
|
+
export async function beginTransaction(projectRoot, description) {
|
|
1041
|
+
const releaseLock = acquireProjectLock(projectRoot);
|
|
1042
|
+
try {
|
|
1043
|
+
const txId = `${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
1044
|
+
const txDir = join(projectRoot, ".actiondock", "transactions", txId);
|
|
1045
|
+
const snapshotDir = join(txDir, "snapshot");
|
|
1046
|
+
mkdirSync(snapshotDir, { recursive: true });
|
|
1047
|
+
const fileRecords = [];
|
|
1048
|
+
for (const file of SNAPSHOT_TRACKED_FILES) {
|
|
1049
|
+
const srcPath = join(projectRoot, file);
|
|
1050
|
+
if (existsSync(srcPath)) {
|
|
1051
|
+
const destPath = join(snapshotDir, file);
|
|
1052
|
+
copyFileSync(srcPath, destPath);
|
|
1053
|
+
fileRecords.push({ name: file, existed: true });
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
fileRecords.push({ name: file, existed: false });
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const meta = {
|
|
1060
|
+
id: txId,
|
|
1061
|
+
status: "pending",
|
|
1062
|
+
createdAt: Date.now(),
|
|
1063
|
+
description,
|
|
1064
|
+
files: fileRecords,
|
|
1065
|
+
};
|
|
1066
|
+
writeFileSync(join(txDir, "transaction.json"), JSON.stringify(meta, null, 2) + "\n", "utf-8");
|
|
1067
|
+
let isFinalized = false;
|
|
1068
|
+
const commit = async () => {
|
|
1069
|
+
if (isFinalized)
|
|
1070
|
+
return;
|
|
1071
|
+
isFinalized = true;
|
|
1072
|
+
meta.status = "committed";
|
|
1073
|
+
try {
|
|
1074
|
+
writeFileSync(join(txDir, "transaction.json"), JSON.stringify(meta, null, 2) + "\n", "utf-8");
|
|
1075
|
+
// 成功提交后清理事务目录
|
|
1076
|
+
rmSync(txDir, { recursive: true, force: true });
|
|
1077
|
+
}
|
|
1078
|
+
finally {
|
|
1079
|
+
releaseLock();
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
const rollback = async (options) => {
|
|
1083
|
+
if (isFinalized)
|
|
1084
|
+
return;
|
|
1085
|
+
isFinalized = true;
|
|
1086
|
+
try {
|
|
1087
|
+
// 逐个恢复快照文件
|
|
1088
|
+
for (const rec of fileRecords) {
|
|
1089
|
+
const targetPath = join(projectRoot, rec.name);
|
|
1090
|
+
const snapPath = join(snapshotDir, rec.name);
|
|
1091
|
+
if (rec.existed) {
|
|
1092
|
+
if (existsSync(snapPath)) {
|
|
1093
|
+
copyFileSync(snapPath, targetPath);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
else {
|
|
1097
|
+
// 原先不存在的文件若在事务中被创建,予以删除
|
|
1098
|
+
if (existsSync(targetPath)) {
|
|
1099
|
+
unlinkSync(targetPath);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
// 执行冻结安装使 node_modules 与恢复后的声明重新一致
|
|
1104
|
+
if (options?.frozenInstall !== false) {
|
|
1105
|
+
runFrozenInstall(projectRoot);
|
|
1106
|
+
}
|
|
1107
|
+
meta.status = "rolled_back";
|
|
1108
|
+
writeFileSync(join(txDir, "transaction.json"), JSON.stringify(meta, null, 2) + "\n", "utf-8");
|
|
1109
|
+
rmSync(txDir, { recursive: true, force: true });
|
|
1110
|
+
}
|
|
1111
|
+
finally {
|
|
1112
|
+
releaseLock();
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
return {
|
|
1116
|
+
id: txId,
|
|
1117
|
+
projectRoot,
|
|
1118
|
+
description,
|
|
1119
|
+
commit,
|
|
1120
|
+
rollback,
|
|
1121
|
+
releaseLock,
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
catch (err) {
|
|
1125
|
+
releaseLock();
|
|
1126
|
+
throw err;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* 依据事务日志恢复所有待恢复的悬空事务快照,并在成功后执行冻结安装。
|
|
1131
|
+
* 进入恢复前通过 acquireProjectLock 原子获取排他锁,持有锁执行恢复,完成后通过释放函数安全释放锁。
|
|
1132
|
+
* 若获取锁失败(已有活跃进程正在执行)则安全退出并返回空数组,杜绝 TOCTOU 竞争。
|
|
1133
|
+
* 若冻结安装失败则抛出 PROJECT_RECOVERY_REQUIRED 并阻止 Host 启动。
|
|
1134
|
+
*/
|
|
1135
|
+
export async function recoverPendingTransactions(projectRoot, options) {
|
|
1136
|
+
const txBaseDir = join(projectRoot, ".actiondock", "transactions");
|
|
1137
|
+
if (!existsSync(txBaseDir)) {
|
|
1138
|
+
return [];
|
|
1139
|
+
}
|
|
1140
|
+
let releaseLock;
|
|
1141
|
+
try {
|
|
1142
|
+
releaseLock = acquireProjectLock(projectRoot);
|
|
1143
|
+
}
|
|
1144
|
+
catch (err) {
|
|
1145
|
+
if (err?.code === "PROJECT_BUSY" || err?.message?.includes("PROJECT_BUSY")) {
|
|
1146
|
+
// 仅当项目锁被其他活跃进程占用时,安全退出并返回空数组
|
|
1147
|
+
return [];
|
|
1148
|
+
}
|
|
1149
|
+
// 底层系统异常(如 EACCES、ENOSPC、ENOENT 等),向外抛出,严禁静默吞掉
|
|
1150
|
+
throw err;
|
|
1151
|
+
}
|
|
1152
|
+
try {
|
|
1153
|
+
const recoveredIds = [];
|
|
1154
|
+
const entries = readdirSync(txBaseDir);
|
|
1155
|
+
for (const entry of entries) {
|
|
1156
|
+
const txDir = join(txBaseDir, entry);
|
|
1157
|
+
const metaPath = join(txDir, "transaction.json");
|
|
1158
|
+
if (!existsSync(metaPath))
|
|
1159
|
+
continue;
|
|
1160
|
+
let meta;
|
|
1161
|
+
try {
|
|
1162
|
+
meta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
if (meta.status === "pending") {
|
|
1168
|
+
const snapshotDir = join(txDir, "snapshot");
|
|
1169
|
+
// 逐个恢复快照文件
|
|
1170
|
+
for (const rec of meta.files) {
|
|
1171
|
+
const targetPath = join(projectRoot, rec.name);
|
|
1172
|
+
const snapPath = join(snapshotDir, rec.name);
|
|
1173
|
+
if (rec.existed) {
|
|
1174
|
+
if (existsSync(snapPath)) {
|
|
1175
|
+
copyFileSync(snapPath, targetPath);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
else {
|
|
1179
|
+
if (existsSync(targetPath)) {
|
|
1180
|
+
unlinkSync(targetPath);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (options?.frozenInstall !== false) {
|
|
1185
|
+
runFrozenInstall(projectRoot);
|
|
1186
|
+
}
|
|
1187
|
+
meta.status = "rolled_back";
|
|
1188
|
+
try {
|
|
1189
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf-8");
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
// 忽略写入异常
|
|
1193
|
+
}
|
|
1194
|
+
rmSync(txDir, { recursive: true, force: true });
|
|
1195
|
+
recoveredIds.push(meta.id);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return recoveredIds;
|
|
1199
|
+
}
|
|
1200
|
+
finally {
|
|
1201
|
+
releaseLock();
|
|
1202
|
+
}
|
|
1203
|
+
}
|