@openclaw/crabline 0.1.9 → 0.1.11

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 (189) hide show
  1. package/README.md +47 -22
  2. package/assets/crabline-banner.svg +20 -0
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +25 -1
  5. package/dist/src/cli/program.js +579 -82
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +59 -6
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +116 -92
  10. package/dist/src/config/schema.js +179 -73
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.js +9 -2
  13. package/dist/src/core/errors.js.map +1 -1
  14. package/dist/src/core/exit-codes.js.map +1 -1
  15. package/dist/src/core/matcher.js +7 -6
  16. package/dist/src/core/matcher.js.map +1 -1
  17. package/dist/src/core/message-template.js.map +1 -1
  18. package/dist/src/core/nonces.d.ts +1 -0
  19. package/dist/src/core/nonces.js +4 -2
  20. package/dist/src/core/nonces.js.map +1 -1
  21. package/dist/src/core/reporters.d.ts +1 -0
  22. package/dist/src/core/reporters.js +42 -4
  23. package/dist/src/core/reporters.js.map +1 -1
  24. package/dist/src/core/run.d.ts +3 -0
  25. package/dist/src/core/run.js +415 -107
  26. package/dist/src/core/run.js.map +1 -1
  27. package/dist/src/core/safe-regex.d.ts +3 -0
  28. package/dist/src/core/safe-regex.js +21 -0
  29. package/dist/src/core/safe-regex.js.map +1 -0
  30. package/dist/src/index.d.ts +2 -2
  31. package/dist/src/index.js +1 -1
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/openclaw/artifact-generation.d.ts +43 -0
  34. package/dist/src/openclaw/artifact-generation.js +363 -0
  35. package/dist/src/openclaw/artifact-generation.js.map +1 -0
  36. package/dist/src/openclaw/bridges/matrix.js +97 -12
  37. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  38. package/dist/src/openclaw/bridges/mattermost.js +32 -10
  39. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  40. package/dist/src/openclaw/bridges/signal.js +55 -18
  41. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  42. package/dist/src/openclaw/bridges/slack.js +85 -7
  43. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  44. package/dist/src/openclaw/bridges/telegram.js +124 -30
  45. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  46. package/dist/src/openclaw/bridges/whatsapp.js +78 -17
  47. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  48. package/dist/src/openclaw/bridges/zalo.js +14 -5
  49. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  50. package/dist/src/openclaw/outbound-contract.d.ts +5 -0
  51. package/dist/src/openclaw/outbound-contract.js +40 -0
  52. package/dist/src/openclaw/outbound-contract.js.map +1 -0
  53. package/dist/src/openclaw/private-file.d.ts +28 -0
  54. package/dist/src/openclaw/private-file.js +465 -0
  55. package/dist/src/openclaw/private-file.js.map +1 -0
  56. package/dist/src/openclaw/shared.d.ts +21 -2
  57. package/dist/src/openclaw/shared.js +115 -18
  58. package/dist/src/openclaw/shared.js.map +1 -1
  59. package/dist/src/openclaw/smoke-lock.d.ts +63 -0
  60. package/dist/src/openclaw/smoke-lock.js +1004 -0
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -0
  62. package/dist/src/openclaw.d.ts +16 -8
  63. package/dist/src/openclaw.js +334 -52
  64. package/dist/src/openclaw.js.map +1 -1
  65. package/dist/src/providers/builtin/discord.d.ts +10 -1
  66. package/dist/src/providers/builtin/discord.js +144 -18
  67. package/dist/src/providers/builtin/discord.js.map +1 -1
  68. package/dist/src/providers/builtin/external-webhook-auth.d.ts +11 -0
  69. package/dist/src/providers/builtin/external-webhook-auth.js +10 -0
  70. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -0
  71. package/dist/src/providers/builtin/feishu.d.ts +19 -2
  72. package/dist/src/providers/builtin/feishu.js +307 -25
  73. package/dist/src/providers/builtin/feishu.js.map +1 -1
  74. package/dist/src/providers/builtin/googlechat.d.ts +20 -1
  75. package/dist/src/providers/builtin/googlechat.js +178 -19
  76. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  77. package/dist/src/providers/builtin/imessage.d.ts +4 -0
  78. package/dist/src/providers/builtin/imessage.js +26 -11
  79. package/dist/src/providers/builtin/imessage.js.map +1 -1
  80. package/dist/src/providers/builtin/loopback.js +92 -30
  81. package/dist/src/providers/builtin/loopback.js.map +1 -1
  82. package/dist/src/providers/builtin/matrix.d.ts +11 -1
  83. package/dist/src/providers/builtin/matrix.js +53 -29
  84. package/dist/src/providers/builtin/matrix.js.map +1 -1
  85. package/dist/src/providers/builtin/mattermost.d.ts +10 -0
  86. package/dist/src/providers/builtin/mattermost.js +38 -15
  87. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  88. package/dist/src/providers/builtin/msteams.d.ts +17 -1
  89. package/dist/src/providers/builtin/msteams.js +113 -14
  90. package/dist/src/providers/builtin/msteams.js.map +1 -1
  91. package/dist/src/providers/builtin/native-local-mock.d.ts +2 -9
  92. package/dist/src/providers/builtin/native-local-mock.js +12 -35
  93. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  94. package/dist/src/providers/builtin/script.d.ts +11 -15
  95. package/dist/src/providers/builtin/script.js +566 -104
  96. package/dist/src/providers/builtin/script.js.map +1 -1
  97. package/dist/src/providers/builtin/slack.d.ts +6 -0
  98. package/dist/src/providers/builtin/slack.js +99 -40
  99. package/dist/src/providers/builtin/slack.js.map +1 -1
  100. package/dist/src/providers/builtin/telegram.d.ts +6 -0
  101. package/dist/src/providers/builtin/telegram.js +64 -43
  102. package/dist/src/providers/builtin/telegram.js.map +1 -1
  103. package/dist/src/providers/builtin/whatsapp.d.ts +28 -8
  104. package/dist/src/providers/builtin/whatsapp.js +567 -30
  105. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  106. package/dist/src/providers/builtin/zalo.js +30 -19
  107. package/dist/src/providers/builtin/zalo.js.map +1 -1
  108. package/dist/src/providers/catalog.d.ts +3 -3
  109. package/dist/src/providers/catalog.js +3 -3
  110. package/dist/src/providers/catalog.js.map +1 -1
  111. package/dist/src/providers/local-mock.d.ts +11 -2
  112. package/dist/src/providers/local-mock.js +322 -96
  113. package/dist/src/providers/local-mock.js.map +1 -1
  114. package/dist/src/providers/native-ids.d.ts +1 -0
  115. package/dist/src/providers/native-ids.js +6 -1
  116. package/dist/src/providers/native-ids.js.map +1 -1
  117. package/dist/src/providers/recorder.d.ts +30 -2
  118. package/dist/src/providers/recorder.js +549 -70
  119. package/dist/src/providers/recorder.js.map +1 -1
  120. package/dist/src/providers/registry.d.ts +25 -1
  121. package/dist/src/providers/registry.js +280 -40
  122. package/dist/src/providers/registry.js.map +1 -1
  123. package/dist/src/providers/signed-jwt.d.ts +29 -0
  124. package/dist/src/providers/signed-jwt.js +202 -0
  125. package/dist/src/providers/signed-jwt.js.map +1 -0
  126. package/dist/src/providers/slack-ids.js.map +1 -1
  127. package/dist/src/providers/target-normalizers.d.ts +26 -0
  128. package/dist/src/providers/target-normalizers.js +276 -0
  129. package/dist/src/providers/target-normalizers.js.map +1 -0
  130. package/dist/src/providers/types.d.ts +5 -0
  131. package/dist/src/providers/types.js.map +1 -1
  132. package/dist/src/providers/webhook-server.d.ts +2 -0
  133. package/dist/src/providers/webhook-server.js +72 -25
  134. package/dist/src/providers/webhook-server.js.map +1 -1
  135. package/dist/src/servers/http.d.ts +23 -3
  136. package/dist/src/servers/http.js +213 -20
  137. package/dist/src/servers/http.js.map +1 -1
  138. package/dist/src/servers/index.js +20 -21
  139. package/dist/src/servers/index.js.map +1 -1
  140. package/dist/src/servers/matrix.js +465 -51
  141. package/dist/src/servers/matrix.js.map +1 -1
  142. package/dist/src/servers/mattermost.d.ts +5 -0
  143. package/dist/src/servers/mattermost.js +306 -58
  144. package/dist/src/servers/mattermost.js.map +1 -1
  145. package/dist/src/servers/pending-events.d.ts +2 -0
  146. package/dist/src/servers/pending-events.js +11 -0
  147. package/dist/src/servers/pending-events.js.map +1 -0
  148. package/dist/src/servers/recorder.d.ts +5 -0
  149. package/dist/src/servers/recorder.js +262 -3
  150. package/dist/src/servers/recorder.js.map +1 -1
  151. package/dist/src/servers/signal.d.ts +2 -0
  152. package/dist/src/servers/signal.js +444 -56
  153. package/dist/src/servers/signal.js.map +1 -1
  154. package/dist/src/servers/slack.d.ts +5 -0
  155. package/dist/src/servers/slack.js +515 -49
  156. package/dist/src/servers/slack.js.map +1 -1
  157. package/dist/src/servers/telegram.d.ts +111 -0
  158. package/dist/src/servers/telegram.js +830 -158
  159. package/dist/src/servers/telegram.js.map +1 -1
  160. package/dist/src/servers/webhook-target.d.ts +40 -0
  161. package/dist/src/servers/webhook-target.js +349 -0
  162. package/dist/src/servers/webhook-target.js.map +1 -0
  163. package/dist/src/servers/websocket.d.ts +2 -0
  164. package/dist/src/servers/websocket.js +29 -0
  165. package/dist/src/servers/websocket.js.map +1 -0
  166. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
  167. package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
  168. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  169. package/dist/src/servers/whatsapp-jid.d.ts +5 -0
  170. package/dist/src/servers/whatsapp-jid.js +32 -0
  171. package/dist/src/servers/whatsapp-jid.js.map +1 -0
  172. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
  173. package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
  174. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  175. package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
  176. package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
  177. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  178. package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
  179. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  180. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  181. package/dist/src/servers/whatsapp.d.ts +12 -7
  182. package/dist/src/servers/whatsapp.js +269 -107
  183. package/dist/src/servers/whatsapp.js.map +1 -1
  184. package/dist/src/servers/zalo.d.ts +14 -0
  185. package/dist/src/servers/zalo.js +521 -88
  186. package/dist/src/servers/zalo.js.map +1 -1
  187. package/docs/channel-setup.md +197 -19
  188. package/fixtures/examples/crabline.example.yaml +10 -0
  189. package/package.json +20 -15
@@ -0,0 +1,1004 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+ import fs, {} from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { performance } from "node:perf_hooks";
7
+ import { isCrablineServerChannel } from "../servers/index.js";
8
+ import { resolveWindowsPowerShellPath, securePrivateDirectory } from "./private-file.js";
9
+ import { OPENCLAW_CRABLINE_MANIFEST_PATH } from "./shared.js";
10
+ const LOCK_OWNER_FILE = "owner.json";
11
+ const LOCK_COMMIT_STAGE_FILE_PREFIX = "commit-stage";
12
+ const LOCK_LEASE_FILE_PREFIX = "lease.";
13
+ const LEGACY_RECOVERY_SUFFIX = ".recovering";
14
+ const COMMIT_CLAIM_SUFFIX = ".commit";
15
+ const RELEASE_CLAIM_SUFFIX = ".release";
16
+ const LOCK_LEASE_MS = 10 * 60 * 1000;
17
+ const RELEASE_ATTEMPTS = 3;
18
+ const RELEASE_RETRY_DELAY_MS = 10;
19
+ const MAX_PROCESS_ID = 2_147_483_647;
20
+ const CURRENT_PROCESS_STARTED_AT_MS = processStartedAtMsFromTimeOrigin(performance.timeOrigin);
21
+ export function processStartedAtMsFromTimeOrigin(timeOrigin) {
22
+ if (!Number.isFinite(timeOrigin) ||
23
+ timeOrigin <= 0 ||
24
+ !Number.isSafeInteger(Math.trunc(timeOrigin))) {
25
+ throw new Error("Process time origin is invalid.");
26
+ }
27
+ return Math.trunc(timeOrigin);
28
+ }
29
+ function parseCommitStagePath(contents, token) {
30
+ let value;
31
+ try {
32
+ value = JSON.parse(contents);
33
+ }
34
+ catch (error) {
35
+ throw new Error("OpenClaw Crabline smoke lock commit stage metadata is malformed.", {
36
+ cause: error,
37
+ });
38
+ }
39
+ const expectedPrefix = `.commit-file.${token}.`;
40
+ if (value.token !== token ||
41
+ typeof value.path !== "string" ||
42
+ !path.isAbsolute(value.path) ||
43
+ !path.basename(value.path).startsWith(expectedPrefix) ||
44
+ !path.basename(value.path).endsWith(".tmp")) {
45
+ throw new Error("OpenClaw Crabline smoke lock commit stage metadata is malformed.");
46
+ }
47
+ return value.path;
48
+ }
49
+ async function readCommitStagePath(lockDirectory, token) {
50
+ try {
51
+ return parseCommitStagePath(await fs.readFile(path.join(lockDirectory, commitStageFileName(token)), "utf8"), token);
52
+ }
53
+ catch (error) {
54
+ if (isMissingPathError(error)) {
55
+ return null;
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+ function commitStageFileName(token) {
61
+ return `${LOCK_COMMIT_STAGE_FILE_PREFIX}.${token}.json`;
62
+ }
63
+ async function writeCommitStagePath(lockDirectory, stagedFilePath, token) {
64
+ const temporaryPath = path.join(lockDirectory, `.${commitStageFileName(token)}.${randomUUID()}.tmp`);
65
+ try {
66
+ await fs.writeFile(temporaryPath, `${JSON.stringify({ path: stagedFilePath, token })}\n`, {
67
+ encoding: "utf8",
68
+ flag: "wx",
69
+ mode: 0o600,
70
+ });
71
+ await fs.chmod(temporaryPath, 0o600);
72
+ await fs.rename(temporaryPath, path.join(lockDirectory, commitStageFileName(token)));
73
+ }
74
+ finally {
75
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
76
+ }
77
+ }
78
+ const removeLockDirectory = async (lockDirectory) => {
79
+ await fs.rm(lockDirectory, { force: true, recursive: true });
80
+ };
81
+ const sleep = async (delayMs) => {
82
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
83
+ };
84
+ const startHeartbeat = (renew, intervalMs) => {
85
+ let failure;
86
+ let pending;
87
+ let stopped = false;
88
+ let timer;
89
+ const schedule = () => {
90
+ if (stopped) {
91
+ return;
92
+ }
93
+ timer = setTimeout(() => {
94
+ timer = undefined;
95
+ pending = renew()
96
+ .catch((error) => {
97
+ failure ??= error;
98
+ })
99
+ .finally(() => {
100
+ pending = undefined;
101
+ schedule();
102
+ });
103
+ }, intervalMs);
104
+ timer.unref();
105
+ };
106
+ schedule();
107
+ return {
108
+ assertHealthy() {
109
+ if (failure !== undefined) {
110
+ throw new Error("OpenClaw Crabline smoke lock heartbeat failed.", { cause: failure });
111
+ }
112
+ },
113
+ async settle() {
114
+ await pending;
115
+ },
116
+ async stop() {
117
+ stopped = true;
118
+ if (timer) {
119
+ clearTimeout(timer);
120
+ timer = undefined;
121
+ }
122
+ await pending;
123
+ },
124
+ };
125
+ };
126
+ async function pathExists(filePath) {
127
+ try {
128
+ await fs.access(filePath);
129
+ return true;
130
+ }
131
+ catch (error) {
132
+ if (isMissingPathError(error)) {
133
+ return false;
134
+ }
135
+ throw error;
136
+ }
137
+ }
138
+ async function readDirectoryIdentity(directoryPath) {
139
+ try {
140
+ const stats = await fs.lstat(directoryPath, { bigint: true });
141
+ if (!stats.isDirectory() || stats.ino <= 0n) {
142
+ throw new Error("OpenClaw Crabline smoke lock claim is not a directory.");
143
+ }
144
+ return {
145
+ device: stats.dev,
146
+ inode: stats.ino,
147
+ };
148
+ }
149
+ catch (error) {
150
+ if (isMissingPathError(error)) {
151
+ return null;
152
+ }
153
+ throw error;
154
+ }
155
+ }
156
+ function hasSameDirectoryIdentity(left, right) {
157
+ return (left !== null && right !== null && left.device === right.device && left.inode === right.inode);
158
+ }
159
+ function isPositiveSafeInteger(value) {
160
+ return Number.isSafeInteger(value) && Number(value) > 0;
161
+ }
162
+ function isValidProcessId(value) {
163
+ return isPositiveSafeInteger(value) && Number(value) <= MAX_PROCESS_ID;
164
+ }
165
+ const LOCK_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
166
+ function parseLockOwner(contents) {
167
+ let owner;
168
+ try {
169
+ owner = JSON.parse(contents);
170
+ }
171
+ catch (error) {
172
+ throw new Error("OpenClaw Crabline smoke lock owner metadata is malformed.", {
173
+ cause: error,
174
+ });
175
+ }
176
+ if (typeof owner.channel !== "string" ||
177
+ !isCrablineServerChannel(owner.channel) ||
178
+ !isValidProcessId(owner.pid) ||
179
+ typeof owner.token !== "string" ||
180
+ !LOCK_TOKEN_PATTERN.test(owner.token)) {
181
+ throw new Error("OpenClaw Crabline smoke lock owner metadata is malformed.");
182
+ }
183
+ const hasCreatedAt = owner.createdAtMs !== undefined;
184
+ const hasProcessStartedAt = owner.processStartedAtMs !== undefined;
185
+ if (!hasCreatedAt && !hasProcessStartedAt && owner.leaseVersion === undefined) {
186
+ return {
187
+ channel: owner.channel,
188
+ pid: owner.pid,
189
+ token: owner.token,
190
+ };
191
+ }
192
+ if (!isPositiveSafeInteger(owner.createdAtMs) ||
193
+ !isPositiveSafeInteger(owner.processStartedAtMs) ||
194
+ (owner.processIdentity !== undefined &&
195
+ (typeof owner.processIdentity !== "string" ||
196
+ owner.processIdentity.length === 0 ||
197
+ owner.processIdentity.length > 256))) {
198
+ throw new Error("OpenClaw Crabline smoke lock owner metadata is malformed.");
199
+ }
200
+ if (owner.leaseVersion === undefined) {
201
+ return {
202
+ channel: owner.channel,
203
+ createdAtMs: owner.createdAtMs,
204
+ pid: owner.pid,
205
+ ...(owner.processIdentity ? { processIdentity: owner.processIdentity } : {}),
206
+ processStartedAtMs: owner.processStartedAtMs,
207
+ token: owner.token,
208
+ };
209
+ }
210
+ if (owner.leaseVersion !== 1) {
211
+ throw new Error("OpenClaw Crabline smoke lock owner metadata is malformed.");
212
+ }
213
+ return {
214
+ channel: owner.channel,
215
+ createdAtMs: owner.createdAtMs,
216
+ leaseVersion: owner.leaseVersion,
217
+ pid: owner.pid,
218
+ ...(owner.processIdentity ? { processIdentity: owner.processIdentity } : {}),
219
+ processStartedAtMs: owner.processStartedAtMs,
220
+ token: owner.token,
221
+ };
222
+ }
223
+ function leaseFileName(token) {
224
+ return `${LOCK_LEASE_FILE_PREFIX}${token}.json`;
225
+ }
226
+ async function readLockRecordFromHandle(handle) {
227
+ const owner = parseLockOwner(await handle.readFile("utf8"));
228
+ const stats = await handle.stat();
229
+ return {
230
+ owner,
231
+ renewedAtMs: Number.isFinite(stats.mtimeMs) && stats.mtimeMs > 0 ? stats.mtimeMs : 0,
232
+ };
233
+ }
234
+ function isMissingPathError(error) {
235
+ return error.code === "ENOENT";
236
+ }
237
+ async function readLockRecord(lockDirectory) {
238
+ let handle;
239
+ try {
240
+ handle = await fs.open(path.join(lockDirectory, LOCK_OWNER_FILE), "r");
241
+ const record = await readLockRecordFromHandle(handle);
242
+ if (isRenewableOwner(record.owner)) {
243
+ try {
244
+ const leaseStats = await fs.stat(path.join(lockDirectory, leaseFileName(record.owner.token)));
245
+ if (Number.isFinite(leaseStats.mtimeMs) && leaseStats.mtimeMs > 0) {
246
+ record.renewedAtMs = Math.max(record.renewedAtMs, leaseStats.mtimeMs);
247
+ }
248
+ }
249
+ catch (error) {
250
+ if (!isMissingPathError(error)) {
251
+ throw error;
252
+ }
253
+ }
254
+ }
255
+ return { kind: "record", record };
256
+ }
257
+ catch (error) {
258
+ if (isMissingPathError(error)) {
259
+ return { kind: "missing" };
260
+ }
261
+ throw error;
262
+ }
263
+ finally {
264
+ await handle?.close();
265
+ }
266
+ }
267
+ function hasProcessIdentity(owner) {
268
+ return "processStartedAtMs" in owner;
269
+ }
270
+ function isRenewableOwner(owner) {
271
+ return "leaseVersion" in owner && owner.leaseVersion === 1;
272
+ }
273
+ export const isProcessAlive = (pid) => {
274
+ if (!isValidProcessId(pid)) {
275
+ return false;
276
+ }
277
+ try {
278
+ process.kill(pid, 0);
279
+ return true;
280
+ }
281
+ catch (error) {
282
+ return error.code === "EPERM";
283
+ }
284
+ };
285
+ export function processIdentityFromLinuxStat(value, bootId) {
286
+ const normalizedBootId = bootId.trim();
287
+ if (!/^[0-9a-f-]{16,64}$/iu.test(normalizedBootId)) {
288
+ return null;
289
+ }
290
+ const commandEnd = value.lastIndexOf(") ");
291
+ if (commandEnd < 0) {
292
+ return null;
293
+ }
294
+ const fields = value
295
+ .slice(commandEnd + 2)
296
+ .trim()
297
+ .split(/\s+/u);
298
+ const startTicks = fields[19];
299
+ if (!startTicks || !/^\d+$/u.test(startTicks)) {
300
+ return null;
301
+ }
302
+ return `linux:${normalizedBootId}:${startTicks}`;
303
+ }
304
+ export function processIdentityFromDarwin(processStartedAt, bootTime) {
305
+ const bootMatch = /\bsec = (\d+), usec = (\d+)\b/u.exec(bootTime);
306
+ const normalizedStartedAt = processStartedAt.trim().replace(/\s+/gu, " ");
307
+ if (!bootMatch || normalizedStartedAt.length === 0 || normalizedStartedAt.length > 64) {
308
+ return null;
309
+ }
310
+ return `darwin:${bootMatch[1]}.${bootMatch[2]}:${normalizedStartedAt}`;
311
+ }
312
+ export function darwinProcessIdentityEnvironment(environment) {
313
+ return { ...environment, LC_ALL: "C", TZ: "UTC" };
314
+ }
315
+ const getProcessIdentity = (pid) => {
316
+ if (process.platform === "linux") {
317
+ try {
318
+ return processIdentityFromLinuxStat(readFileSync(`/proc/${pid}/stat`, "utf8"), readFileSync("/proc/sys/kernel/random/boot_id", "utf8"));
319
+ }
320
+ catch {
321
+ return null;
322
+ }
323
+ }
324
+ if (process.platform === "darwin") {
325
+ const options = {
326
+ encoding: "utf8",
327
+ env: darwinProcessIdentityEnvironment(process.env),
328
+ timeout: 1_000,
329
+ };
330
+ const startedAt = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], options);
331
+ const bootTime = spawnSync("sysctl", ["-n", "kern.boottime"], options);
332
+ return startedAt.status === 0 && bootTime.status === 0
333
+ ? processIdentityFromDarwin(startedAt.stdout, bootTime.stdout)
334
+ : null;
335
+ }
336
+ if (process.platform !== "win32") {
337
+ return null;
338
+ }
339
+ let powershellPath;
340
+ try {
341
+ powershellPath = resolveWindowsPowerShellPath(process.env.SystemRoot);
342
+ }
343
+ catch {
344
+ return null;
345
+ }
346
+ const result = spawnSync(powershellPath, [
347
+ "-NoProfile",
348
+ "-NonInteractive",
349
+ "-Command",
350
+ `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks.ToString()`,
351
+ ], { encoding: "utf8", timeout: 1_000, windowsHide: true });
352
+ const ticks = result.status === 0 ? result.stdout.trim() : "";
353
+ return /^\d+$/u.test(ticks) ? `windows:${ticks}` : null;
354
+ };
355
+ function hasExactProcessIdentity(owner) {
356
+ return hasProcessIdentity(owner) && typeof owner.processIdentity === "string";
357
+ }
358
+ function hasProcessIdentityMismatch(owner, runtime) {
359
+ if (hasProcessIdentity(owner) &&
360
+ owner.pid === runtime.currentPid &&
361
+ owner.processStartedAtMs !== runtime.currentProcessStartedAtMs) {
362
+ return true;
363
+ }
364
+ if (!hasExactProcessIdentity(owner)) {
365
+ return false;
366
+ }
367
+ const actualProcessIdentity = owner.pid === runtime.currentPid
368
+ ? runtime.currentProcessIdentity
369
+ : runtime.getProcessIdentity(owner.pid);
370
+ return actualProcessIdentity !== null && owner.processIdentity !== actualProcessIdentity;
371
+ }
372
+ function isLockOwnerActive(record, runtime) {
373
+ const { owner } = record;
374
+ if (!runtime.isProcessAlive(owner.pid)) {
375
+ return false;
376
+ }
377
+ if (hasProcessIdentityMismatch(owner, runtime)) {
378
+ return false;
379
+ }
380
+ if (!isRenewableOwner(owner)) {
381
+ return true;
382
+ }
383
+ const ageMs = runtime.now() - Math.max(owner.createdAtMs, record.renewedAtMs);
384
+ return ageMs >= -runtime.leaseMs && ageMs <= runtime.leaseMs;
385
+ }
386
+ function needsLiveOwnerConfirmation(record, runtime) {
387
+ return (isRenewableOwner(record.owner) &&
388
+ runtime.isProcessAlive(record.owner.pid) &&
389
+ !hasProcessIdentityMismatch(record.owner, runtime) &&
390
+ !isLockOwnerActive(record, runtime));
391
+ }
392
+ async function confirmObservedOwner(lockDirectory, observed, runtime) {
393
+ if (!needsLiveOwnerConfirmation(observed, runtime)) {
394
+ return "unchanged";
395
+ }
396
+ await runtime.sleep(runtime.leaseMs);
397
+ const revalidated = await readLockRecord(lockDirectory);
398
+ if (revalidated.kind !== "record") {
399
+ return "unchanged";
400
+ }
401
+ if (revalidated.record.owner.token !== observed.owner.token) {
402
+ return "changed";
403
+ }
404
+ return revalidated.record.renewedAtMs !== observed.renewedAtMs ||
405
+ isLockOwnerActive(revalidated.record, runtime)
406
+ ? "renewed"
407
+ : "unchanged";
408
+ }
409
+ function activeRunError(params) {
410
+ return new Error(`OpenClaw Crabline smoke is already running for channel "${params.channel ?? "unknown"}" in "${params.outputDir}"; cannot start channel "${params.requestedChannel}".`, params.cause === undefined ? undefined : { cause: params.cause });
411
+ }
412
+ async function removeOwnedLock(params) {
413
+ const observed = await readLockRecord(params.ownedDirectory);
414
+ if (observed.kind === "missing" || observed.record.owner.token !== params.token) {
415
+ return false;
416
+ }
417
+ const observedIdentity = await readDirectoryIdentity(params.ownedDirectory);
418
+ if (observedIdentity === null) {
419
+ return false;
420
+ }
421
+ await params.beforeClaim?.();
422
+ const releaseClaim = `${params.lockDirectory}${RELEASE_CLAIM_SUFFIX}.${params.token}.${randomUUID()}`;
423
+ try {
424
+ await fs.rename(params.ownedDirectory, releaseClaim);
425
+ }
426
+ catch (error) {
427
+ if (isMissingPathError(error)) {
428
+ return false;
429
+ }
430
+ throw error;
431
+ }
432
+ params.onOwnedDirectoryChange?.(releaseClaim);
433
+ const claimed = await readLockRecord(releaseClaim);
434
+ const claimedIdentity = await readDirectoryIdentity(releaseClaim);
435
+ if (claimed.kind === "missing" ||
436
+ claimed.record.owner.token !== params.token ||
437
+ !hasSameDirectoryIdentity(observedIdentity, claimedIdentity) ||
438
+ (params.runtime !== undefined && isLockOwnerActive(claimed.record, params.runtime))) {
439
+ if (!(await pathExists(params.ownedDirectory))) {
440
+ try {
441
+ await fs.rename(releaseClaim, params.ownedDirectory);
442
+ params.onOwnedDirectoryChange?.(params.ownedDirectory);
443
+ }
444
+ catch (error) {
445
+ if (!isMissingPathError(error)) {
446
+ throw error;
447
+ }
448
+ }
449
+ }
450
+ return false;
451
+ }
452
+ const stagedFilePath = await readCommitStagePath(releaseClaim, params.token);
453
+ if (stagedFilePath) {
454
+ const revokedStagePath = path.join(path.dirname(stagedFilePath), `.revoked-commit-file.${params.token}.${randomUUID()}.tmp`);
455
+ try {
456
+ await fs.rename(stagedFilePath, revokedStagePath);
457
+ await fs.rm(revokedStagePath, { force: true });
458
+ }
459
+ catch (error) {
460
+ if (!isMissingPathError(error)) {
461
+ throw error;
462
+ }
463
+ }
464
+ }
465
+ await (params.removeDirectory ?? removeLockDirectory)(releaseClaim);
466
+ return true;
467
+ }
468
+ async function renewOwnedLock(lockDirectory, token, renewedAtMs) {
469
+ const temporaryLeasePath = path.join(lockDirectory, `.${leaseFileName(token)}.${randomUUID()}.tmp`);
470
+ try {
471
+ const observed = await readLockRecord(lockDirectory);
472
+ if (observed.kind === "missing" ||
473
+ observed.record.owner.token !== token ||
474
+ !isRenewableOwner(observed.record.owner)) {
475
+ return false;
476
+ }
477
+ await fs.writeFile(temporaryLeasePath, `${JSON.stringify({ renewedAtMs, token })}\n`, {
478
+ encoding: "utf8",
479
+ flag: "wx",
480
+ mode: 0o600,
481
+ });
482
+ await fs.chmod(temporaryLeasePath, 0o600);
483
+ const renewedAt = new Date(renewedAtMs);
484
+ await fs.utimes(temporaryLeasePath, renewedAt, renewedAt);
485
+ const revalidated = await readLockRecord(lockDirectory);
486
+ if (revalidated.kind === "missing" ||
487
+ revalidated.record.owner.token !== token ||
488
+ !isRenewableOwner(revalidated.record.owner)) {
489
+ return false;
490
+ }
491
+ await fs.rename(temporaryLeasePath, path.join(lockDirectory, leaseFileName(token)));
492
+ return true;
493
+ }
494
+ catch (error) {
495
+ if (isMissingPathError(error)) {
496
+ return false;
497
+ }
498
+ throw error;
499
+ }
500
+ finally {
501
+ await fs.rm(temporaryLeasePath, { force: true }).catch(() => undefined);
502
+ }
503
+ }
504
+ function claimPrefix(lockDirectory, suffix) {
505
+ return `${path.basename(lockDirectory)}${suffix}.`;
506
+ }
507
+ async function listLockClaims(lockDirectory) {
508
+ const directory = path.dirname(lockDirectory);
509
+ const prefixes = [
510
+ { kind: "recovering", prefix: claimPrefix(lockDirectory, LEGACY_RECOVERY_SUFFIX) },
511
+ { kind: "commit", prefix: claimPrefix(lockDirectory, COMMIT_CLAIM_SUFFIX) },
512
+ { kind: "release", prefix: claimPrefix(lockDirectory, RELEASE_CLAIM_SUFFIX) },
513
+ ];
514
+ const entries = await fs.readdir(directory, { withFileTypes: true });
515
+ const claims = [];
516
+ for (const entry of entries) {
517
+ const match = prefixes.find(({ prefix }) => entry.name.startsWith(prefix));
518
+ if (!match) {
519
+ continue;
520
+ }
521
+ if (!entry.isDirectory()) {
522
+ throw new Error("OpenClaw Crabline smoke lock claim is not a directory.");
523
+ }
524
+ claims.push({
525
+ directoryPath: path.join(directory, entry.name),
526
+ kind: match.kind,
527
+ });
528
+ }
529
+ return claims.sort((left, right) => left.directoryPath.localeCompare(right.directoryPath));
530
+ }
531
+ async function claimLegacyRecoveryDirectory(lockDirectory, expectedToken) {
532
+ const legacyRecoveryDirectory = `${lockDirectory}${LEGACY_RECOVERY_SUFFIX}`;
533
+ if (!(await pathExists(legacyRecoveryDirectory))) {
534
+ return;
535
+ }
536
+ if (expectedToken !== undefined) {
537
+ const observed = await readLockRecord(legacyRecoveryDirectory);
538
+ if (observed.kind === "missing" || observed.record.owner.token !== expectedToken) {
539
+ return;
540
+ }
541
+ }
542
+ try {
543
+ await fs.rename(legacyRecoveryDirectory, `${lockDirectory}${LEGACY_RECOVERY_SUFFIX}.${randomUUID()}`);
544
+ }
545
+ catch (error) {
546
+ if (!isMissingPathError(error)) {
547
+ throw error;
548
+ }
549
+ }
550
+ }
551
+ async function resolveLockClaim(params) {
552
+ const observed = await readLockRecord(params.claim.directoryPath);
553
+ if (observed.kind === "missing") {
554
+ const discardDirectory = `${params.lockDirectory}.discard.${randomUUID()}`;
555
+ try {
556
+ await fs.rename(params.claim.directoryPath, discardDirectory);
557
+ }
558
+ catch (error) {
559
+ if (isMissingPathError(error)) {
560
+ return;
561
+ }
562
+ throw error;
563
+ }
564
+ const claimed = await readLockRecord(discardDirectory);
565
+ if (claimed.kind === "record") {
566
+ await fs.rename(discardDirectory, params.claim.directoryPath);
567
+ await resolveLockClaim(params);
568
+ return;
569
+ }
570
+ await fs.rm(discardDirectory, { force: true, recursive: true });
571
+ return;
572
+ }
573
+ if (isLockOwnerActive(observed.record, params.runtime)) {
574
+ if (params.claim.kind === "recovering") {
575
+ try {
576
+ await fs.rename(params.claim.directoryPath, params.lockDirectory);
577
+ }
578
+ catch (error) {
579
+ if (!(await pathExists(params.lockDirectory))) {
580
+ throw error;
581
+ }
582
+ }
583
+ }
584
+ throw activeRunError({
585
+ channel: observed.record.owner.channel,
586
+ outputDir: params.outputDir,
587
+ requestedChannel: params.requestedChannel,
588
+ });
589
+ }
590
+ const confirmation = await confirmObservedOwner(params.claim.directoryPath, observed.record, params.runtime);
591
+ if (confirmation === "renewed") {
592
+ if (params.claim.kind === "recovering") {
593
+ try {
594
+ await fs.rename(params.claim.directoryPath, params.lockDirectory);
595
+ }
596
+ catch (error) {
597
+ if (!(await pathExists(params.lockDirectory))) {
598
+ throw error;
599
+ }
600
+ }
601
+ }
602
+ throw activeRunError({
603
+ channel: observed.record.owner.channel,
604
+ outputDir: params.outputDir,
605
+ requestedChannel: params.requestedChannel,
606
+ });
607
+ }
608
+ if (confirmation === "changed") {
609
+ await resolveLockClaim(params);
610
+ return;
611
+ }
612
+ const revalidated = await readLockRecord(params.claim.directoryPath);
613
+ if (revalidated.kind === "missing") {
614
+ await resolveLockClaim(params);
615
+ return;
616
+ }
617
+ if (revalidated.record.owner.token !== observed.record.owner.token ||
618
+ isLockOwnerActive(revalidated.record, params.runtime)) {
619
+ await resolveLockClaim(params);
620
+ return;
621
+ }
622
+ if (!(await removeOwnedLock({
623
+ beforeClaim: params.beforeRecoveryDeleteClaim,
624
+ lockDirectory: params.lockDirectory,
625
+ ownedDirectory: params.claim.directoryPath,
626
+ runtime: params.runtime,
627
+ token: revalidated.record.owner.token,
628
+ }))) {
629
+ await resolveLockClaim(params);
630
+ }
631
+ }
632
+ async function resolveLockClaims(params) {
633
+ await claimLegacyRecoveryDirectory(params.lockDirectory);
634
+ for (const claim of await listLockClaims(params.lockDirectory)) {
635
+ await resolveLockClaim({
636
+ ...params,
637
+ claim,
638
+ });
639
+ }
640
+ }
641
+ async function resolveLockContention(params) {
642
+ const observed = await readLockRecord(params.lockDirectory);
643
+ if (observed.kind === "record" && isLockOwnerActive(observed.record, params.runtime)) {
644
+ throw activeRunError({
645
+ cause: params.cause,
646
+ channel: observed.record.owner.channel,
647
+ outputDir: params.outputDir,
648
+ requestedChannel: params.requestedChannel,
649
+ });
650
+ }
651
+ if (observed.kind === "record") {
652
+ const confirmation = await confirmObservedOwner(params.lockDirectory, observed.record, params.runtime);
653
+ if (confirmation === "renewed") {
654
+ throw activeRunError({
655
+ channel: observed.record.owner.channel,
656
+ outputDir: params.outputDir,
657
+ requestedChannel: params.requestedChannel,
658
+ });
659
+ }
660
+ if (confirmation === "changed") {
661
+ await resolveLockContention(params);
662
+ return;
663
+ }
664
+ }
665
+ await params.beforeRecoveryClaim();
666
+ const claimDirectory = `${params.lockDirectory}${LEGACY_RECOVERY_SUFFIX}.${randomUUID()}`;
667
+ try {
668
+ await fs.rename(params.lockDirectory, claimDirectory);
669
+ }
670
+ catch (error) {
671
+ if (!(await pathExists(params.lockDirectory))) {
672
+ await resolveLockClaims(params);
673
+ return;
674
+ }
675
+ throw error;
676
+ }
677
+ await resolveLockClaim({
678
+ ...params,
679
+ claim: {
680
+ directoryPath: claimDirectory,
681
+ kind: "recovering",
682
+ },
683
+ });
684
+ }
685
+ async function createLockCandidate(params) {
686
+ const candidateDirectory = `${params.lockDirectory}.${params.owner.pid}.${params.owner.token}.tmp`;
687
+ await fs.mkdir(candidateDirectory, { mode: 0o700 });
688
+ try {
689
+ const securedDirectory = await securePrivateDirectory(candidateDirectory, {
690
+ ...(params.platform ? { platform: params.platform } : {}),
691
+ ...(params.secureWindowsDirectory
692
+ ? { secureWindowsDirectory: params.secureWindowsDirectory }
693
+ : {}),
694
+ });
695
+ await securedDirectory.assertIdentityAt();
696
+ const ownerPath = path.join(candidateDirectory, LOCK_OWNER_FILE);
697
+ await fs.writeFile(ownerPath, `${JSON.stringify(params.owner)}\n`, {
698
+ encoding: "utf8",
699
+ flag: "wx",
700
+ mode: 0o600,
701
+ });
702
+ await fs.chmod(ownerPath, 0o600);
703
+ const createdAt = new Date(params.owner.createdAtMs);
704
+ await fs.utimes(ownerPath, createdAt, createdAt);
705
+ await params.afterOwnerWrite?.(candidateDirectory);
706
+ await securedDirectory.assertIdentityAt();
707
+ const leasePath = path.join(candidateDirectory, leaseFileName(params.owner.token));
708
+ await fs.writeFile(leasePath, `${JSON.stringify({ renewedAtMs: params.owner.createdAtMs, token: params.owner.token })}\n`, {
709
+ encoding: "utf8",
710
+ flag: "wx",
711
+ mode: 0o600,
712
+ });
713
+ await fs.chmod(leasePath, 0o600);
714
+ await fs.utimes(leasePath, createdAt, createdAt);
715
+ await securedDirectory.assertIdentityAt();
716
+ return { candidateDirectory, securedDirectory };
717
+ }
718
+ catch (error) {
719
+ await fs.rm(candidateDirectory, { force: true, recursive: true }).catch(() => undefined);
720
+ throw error;
721
+ }
722
+ }
723
+ export async function acquireOpenClawCrablineSmokeRunLock(params, dependencies = {}) {
724
+ const outputDir = path.resolve(params.outputDir);
725
+ const lockDirectory = path.join(outputDir, `.${OPENCLAW_CRABLINE_MANIFEST_PATH}.lock`);
726
+ const token = randomUUID();
727
+ const currentPid = dependencies.pid ?? process.pid;
728
+ const getRuntimeProcessIdentity = dependencies.getProcessIdentity ?? getProcessIdentity;
729
+ const runtime = {
730
+ currentPid,
731
+ currentProcessIdentity: dependencies.processIdentity === undefined
732
+ ? getRuntimeProcessIdentity(currentPid)
733
+ : dependencies.processIdentity,
734
+ currentProcessStartedAtMs: dependencies.processStartedAtMs ?? CURRENT_PROCESS_STARTED_AT_MS,
735
+ getProcessIdentity: getRuntimeProcessIdentity,
736
+ isProcessAlive: dependencies.isProcessAlive ?? isProcessAlive,
737
+ leaseMs: dependencies.leaseMs ?? LOCK_LEASE_MS,
738
+ now: dependencies.now ?? Date.now,
739
+ sleep: dependencies.sleep ?? sleep,
740
+ };
741
+ const createdAtMs = runtime.now();
742
+ if (!isValidProcessId(runtime.currentPid) ||
743
+ (runtime.currentProcessIdentity !== null &&
744
+ (runtime.currentProcessIdentity.length === 0 ||
745
+ runtime.currentProcessIdentity.length > 256)) ||
746
+ !isPositiveSafeInteger(runtime.currentProcessStartedAtMs) ||
747
+ !isPositiveSafeInteger(runtime.leaseMs) ||
748
+ !isPositiveSafeInteger(createdAtMs)) {
749
+ throw new Error("Invalid OpenClaw Crabline smoke lock runtime.");
750
+ }
751
+ const owner = {
752
+ channel: params.channel,
753
+ createdAtMs,
754
+ leaseVersion: 1,
755
+ pid: runtime.currentPid,
756
+ ...(runtime.currentProcessIdentity ? { processIdentity: runtime.currentProcessIdentity } : {}),
757
+ processStartedAtMs: runtime.currentProcessStartedAtMs,
758
+ token,
759
+ };
760
+ await fs.mkdir(outputDir, { recursive: true });
761
+ for (;;) {
762
+ const lockClaims = await listLockClaims(lockDirectory);
763
+ if (lockClaims.length > 0 || (await pathExists(`${lockDirectory}${LEGACY_RECOVERY_SUFFIX}`))) {
764
+ await resolveLockClaims({
765
+ beforeRecoveryDeleteClaim: dependencies.beforeRecoveryDeleteClaim ?? (async () => undefined),
766
+ lockDirectory,
767
+ outputDir,
768
+ requestedChannel: params.channel,
769
+ runtime,
770
+ });
771
+ continue;
772
+ }
773
+ const { candidateDirectory, securedDirectory } = await createLockCandidate({
774
+ ...(dependencies.afterLockOwnerWrite
775
+ ? { afterOwnerWrite: dependencies.afterLockOwnerWrite }
776
+ : {}),
777
+ lockDirectory,
778
+ owner,
779
+ ...(dependencies.platform ? { platform: dependencies.platform } : {}),
780
+ ...(dependencies.secureWindowsDirectory
781
+ ? { secureWindowsDirectory: dependencies.secureWindowsDirectory }
782
+ : {}),
783
+ });
784
+ try {
785
+ await fs.rename(candidateDirectory, lockDirectory);
786
+ }
787
+ catch (error) {
788
+ await fs.rm(candidateDirectory, { force: true, recursive: true }).catch(() => undefined);
789
+ await resolveLockContention({
790
+ beforeRecoveryDeleteClaim: dependencies.beforeRecoveryDeleteClaim ?? (async () => undefined),
791
+ cause: error,
792
+ lockDirectory,
793
+ outputDir,
794
+ requestedChannel: params.channel,
795
+ runtime,
796
+ beforeRecoveryClaim: dependencies.beforeRecoveryClaim ?? (async () => undefined),
797
+ });
798
+ continue;
799
+ }
800
+ await dependencies.afterLockCandidateInstall?.(lockDirectory);
801
+ await securedDirectory.assertIdentityAt(lockDirectory);
802
+ if ((await listLockClaims(lockDirectory)).length > 0 ||
803
+ (await pathExists(`${lockDirectory}${LEGACY_RECOVERY_SUFFIX}`))) {
804
+ await removeOwnedLock({
805
+ lockDirectory,
806
+ ownedDirectory: lockDirectory,
807
+ token,
808
+ });
809
+ await resolveLockClaims({
810
+ beforeRecoveryDeleteClaim: dependencies.beforeRecoveryDeleteClaim ?? (async () => undefined),
811
+ lockDirectory,
812
+ outputDir,
813
+ requestedChannel: params.channel,
814
+ runtime,
815
+ });
816
+ continue;
817
+ }
818
+ const installed = await readLockRecord(lockDirectory);
819
+ if (installed.kind !== "record" || installed.record.owner.token !== token) {
820
+ continue;
821
+ }
822
+ let ownedDirectory = lockDirectory;
823
+ let clockRegressed = false;
824
+ let lastObservedNowMs = createdAtMs;
825
+ let lastRenewedAtMs = createdAtMs;
826
+ let pendingRenewal = Promise.resolve();
827
+ let commitFenceConsumed = false;
828
+ let heartbeat;
829
+ const renew = async () => {
830
+ const renewal = pendingRenewal
831
+ .catch(() => undefined)
832
+ .then(async () => {
833
+ const nowMs = runtime.now();
834
+ if (nowMs < lastObservedNowMs) {
835
+ clockRegressed = true;
836
+ }
837
+ else if (clockRegressed && nowMs >= lastRenewedAtMs) {
838
+ clockRegressed = false;
839
+ }
840
+ lastObservedNowMs = nowMs;
841
+ const renewedAtMs = clockRegressed ? lastRenewedAtMs + 1 : nowMs;
842
+ if (!Number.isSafeInteger(renewedAtMs)) {
843
+ throw new Error("OpenClaw Crabline smoke lock renewal timestamp overflowed.");
844
+ }
845
+ if (!(await renewOwnedLock(ownedDirectory, token, renewedAtMs))) {
846
+ throw new Error("OpenClaw Crabline smoke lock ownership was lost.");
847
+ }
848
+ lastRenewedAtMs = renewedAtMs;
849
+ });
850
+ pendingRenewal = renewal;
851
+ await renewal;
852
+ };
853
+ try {
854
+ heartbeat = (dependencies.startHeartbeat ?? startHeartbeat)(renew, Math.max(1, Math.floor(runtime.leaseMs / 3)));
855
+ }
856
+ catch (error) {
857
+ await removeOwnedLock({
858
+ lockDirectory,
859
+ ownedDirectory: lockDirectory,
860
+ token,
861
+ });
862
+ throw error;
863
+ }
864
+ let heartbeatStopped = false;
865
+ let released = false;
866
+ return {
867
+ async assertOwned() {
868
+ if (released) {
869
+ throw new Error("OpenClaw Crabline smoke lock has already been released.");
870
+ }
871
+ heartbeat.assertHealthy();
872
+ await renew();
873
+ heartbeat.assertHealthy();
874
+ },
875
+ async commitFileAtomically({ contents, destinationPath, stageDirectory, stageFile }) {
876
+ if (released) {
877
+ throw new Error("OpenClaw Crabline smoke lock has already been released.");
878
+ }
879
+ if (commitFenceConsumed || heartbeatStopped || ownedDirectory !== lockDirectory) {
880
+ throw new Error("OpenClaw Crabline smoke lock has already committed its fence.");
881
+ }
882
+ heartbeat.assertHealthy();
883
+ await renew();
884
+ heartbeat.assertHealthy();
885
+ const stagedFileName = `.commit-file.${token}.${randomUUID()}.tmp`;
886
+ const stagedFilePath = path.join(stageDirectory ?? lockDirectory, stagedFileName);
887
+ let committed = false;
888
+ try {
889
+ await stageFile(stagedFilePath, contents);
890
+ if (stageDirectory) {
891
+ await writeCommitStagePath(lockDirectory, stagedFilePath, token);
892
+ }
893
+ heartbeat.assertHealthy();
894
+ await renew();
895
+ heartbeat.assertHealthy();
896
+ const observedIdentity = await readDirectoryIdentity(lockDirectory);
897
+ const observed = await readLockRecord(lockDirectory);
898
+ if (observedIdentity === null ||
899
+ observed.kind === "missing" ||
900
+ observed.record.owner.token !== token) {
901
+ throw new Error("OpenClaw Crabline smoke lock ownership was lost.");
902
+ }
903
+ await dependencies.beforeCommitClaim?.();
904
+ const commitClaim = `${lockDirectory}${COMMIT_CLAIM_SUFFIX}.${token}.${randomUUID()}`;
905
+ try {
906
+ await fs.rename(lockDirectory, commitClaim);
907
+ }
908
+ catch (error) {
909
+ if (isMissingPathError(error)) {
910
+ throw new Error("OpenClaw Crabline smoke lock ownership was lost.", {
911
+ cause: error,
912
+ });
913
+ }
914
+ throw error;
915
+ }
916
+ ownedDirectory = commitClaim;
917
+ commitFenceConsumed = true;
918
+ const claimed = await readLockRecord(commitClaim);
919
+ const claimedIdentity = await readDirectoryIdentity(commitClaim);
920
+ if (claimed.kind === "missing" ||
921
+ claimed.record.owner.token !== token ||
922
+ !hasSameDirectoryIdentity(observedIdentity, claimedIdentity)) {
923
+ if (!(await pathExists(lockDirectory))) {
924
+ try {
925
+ await fs.rename(commitClaim, lockDirectory);
926
+ ownedDirectory = lockDirectory;
927
+ }
928
+ catch (error) {
929
+ if (!isMissingPathError(error)) {
930
+ throw error;
931
+ }
932
+ }
933
+ }
934
+ throw new Error("OpenClaw Crabline smoke lock commit fence was lost.");
935
+ }
936
+ await dependencies.beforeCommitFileRename?.();
937
+ heartbeat.assertHealthy();
938
+ await renew();
939
+ heartbeat.assertHealthy();
940
+ await heartbeat.settle();
941
+ heartbeat.assertHealthy();
942
+ await dependencies.beforeCommitRename?.();
943
+ await fs.rename(stageDirectory ? stagedFilePath : path.join(commitClaim, stagedFileName), destinationPath);
944
+ committed = true;
945
+ }
946
+ finally {
947
+ if (!committed && stageDirectory) {
948
+ await fs.rm(stagedFilePath, { force: true }).catch(() => undefined);
949
+ }
950
+ }
951
+ },
952
+ async release() {
953
+ if (released) {
954
+ return;
955
+ }
956
+ if (!heartbeatStopped) {
957
+ await heartbeat.stop();
958
+ heartbeatStopped = true;
959
+ }
960
+ await removeOwnedLock({
961
+ ...(dependencies.beforeReleaseClaim
962
+ ? { beforeClaim: dependencies.beforeReleaseClaim }
963
+ : {}),
964
+ lockDirectory,
965
+ onOwnedDirectoryChange: (directoryPath) => {
966
+ ownedDirectory = directoryPath;
967
+ },
968
+ ownedDirectory,
969
+ ...(dependencies.removeDirectory
970
+ ? { removeDirectory: dependencies.removeDirectory }
971
+ : {}),
972
+ token,
973
+ });
974
+ await claimLegacyRecoveryDirectory(lockDirectory, token);
975
+ for (const claim of await listLockClaims(lockDirectory)) {
976
+ await removeOwnedLock({
977
+ lockDirectory,
978
+ ownedDirectory: claim.directoryPath,
979
+ ...(dependencies.removeDirectory
980
+ ? { removeDirectory: dependencies.removeDirectory }
981
+ : {}),
982
+ token,
983
+ });
984
+ }
985
+ released = true;
986
+ },
987
+ };
988
+ }
989
+ }
990
+ export async function releaseOpenClawCrablineSmokeRunLock(lock, dependencies = {}) {
991
+ for (let attempt = 1;; attempt += 1) {
992
+ try {
993
+ await lock.release();
994
+ return;
995
+ }
996
+ catch (error) {
997
+ if (attempt === RELEASE_ATTEMPTS) {
998
+ throw error;
999
+ }
1000
+ await (dependencies.sleep ?? sleep)(RELEASE_RETRY_DELAY_MS * 2 ** (attempt - 1));
1001
+ }
1002
+ }
1003
+ }
1004
+ //# sourceMappingURL=smoke-lock.js.map