@botbuddy/cli 1.29.2 → 1.29.4
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/package.json +1 -1
- package/src/wait-checkpoint.mjs +410 -0
- package/src/wait-core.mjs +187 -63
- package/src/wait.mjs +807 -21
package/package.json
CHANGED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// BOT-1656 — crash-recovery state for `bb wait`.
|
|
2
|
+
//
|
|
3
|
+
// Checkpoints are deliberately small, worktree-local data. They never contain a
|
|
4
|
+
// credential, command string, or unbounded spine payload; they only preserve the
|
|
5
|
+
// information required to re-arm the *same* logical observation after a harness
|
|
6
|
+
// restarts.
|
|
7
|
+
|
|
8
|
+
import { chmod, link, lstat, mkdir, open, readFile, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
|
9
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
10
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
11
|
+
import { dirname, join, resolve, relative, sep } from "node:path";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
|
|
14
|
+
export const CHECKPOINT_SCHEMA_VERSION = 1;
|
|
15
|
+
export const CHECKPOINT_MAX_BYTES = 256 * 1024;
|
|
16
|
+
// `bb wait` caps a public receipt at 64 KiB. Keep another 8 KiB for the
|
|
17
|
+
// terminal status, ownership, cloud-delivery, and timestamp fields added after
|
|
18
|
+
// initial creation so every accepted checkpoint can durably save its result.
|
|
19
|
+
export const CHECKPOINT_TERMINAL_RESERVE_BYTES = 72 * 1024;
|
|
20
|
+
export const INVENTORY_MAX_ITEMS = 20;
|
|
21
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
22
|
+
const STATUSES = new Set(["preparing", "armed", "interrupted", "terminal", "cancelled", "acknowledged"]);
|
|
23
|
+
const LOCK_MAX_BYTES = 4 * 1024;
|
|
24
|
+
const execFile = promisify(execFileCallback);
|
|
25
|
+
const heldFenceLocks = new Map();
|
|
26
|
+
let installedFenceExitHook = false;
|
|
27
|
+
|
|
28
|
+
export class WaitCheckpointError extends Error {
|
|
29
|
+
constructor(code, message = code) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "WaitCheckpointError";
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function safeId(id) {
|
|
37
|
+
if (typeof id !== "string" || !ID_RE.test(id)) {
|
|
38
|
+
throw new WaitCheckpointError("wait_checkpoint_invalid", "local_wait_id is invalid");
|
|
39
|
+
}
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertUnderRoot(root, path) {
|
|
44
|
+
const rel = relative(root, path);
|
|
45
|
+
if (rel === "" || rel.startsWith(`..${sep}`) || rel === ".." || resolve(root, rel) !== path) {
|
|
46
|
+
throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint is outside its worktree state directory");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validateCheckpoint(value) {
|
|
51
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.schema_version !== CHECKPOINT_SCHEMA_VERSION) {
|
|
52
|
+
throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint schema is unsupported or malformed");
|
|
53
|
+
}
|
|
54
|
+
safeId(value.local_wait_id);
|
|
55
|
+
if (!STATUSES.has(value.status) || typeof value.deadline_at !== "string" || !Number.isFinite(Date.parse(value.deadline_at))) {
|
|
56
|
+
throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint has invalid status or deadline");
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(value.conditions)) {
|
|
59
|
+
throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint conditions are invalid");
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function fsyncDirectory(directory) {
|
|
65
|
+
let handle;
|
|
66
|
+
try { handle = await open(directory, "r"); await handle.sync(); }
|
|
67
|
+
catch { /* Windows/filesystems that cannot fsync a directory still get atomic rename. */ }
|
|
68
|
+
finally { await handle?.close(); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function durableJson(path, value) {
|
|
72
|
+
const text = `${JSON.stringify(value)}\n`;
|
|
73
|
+
if (Buffer.byteLength(text, "utf8") > CHECKPOINT_MAX_BYTES) {
|
|
74
|
+
throw new WaitCheckpointError("wait_checkpoint_write_failed", "checkpoint exceeds 256 KiB");
|
|
75
|
+
}
|
|
76
|
+
const temp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
77
|
+
let handle;
|
|
78
|
+
try {
|
|
79
|
+
handle = await open(temp, "wx", 0o600);
|
|
80
|
+
await handle.writeFile(text);
|
|
81
|
+
await handle.sync();
|
|
82
|
+
await handle.close();
|
|
83
|
+
handle = null;
|
|
84
|
+
await chmod(temp, 0o600);
|
|
85
|
+
await rename(temp, path);
|
|
86
|
+
await chmod(path, 0o600);
|
|
87
|
+
await fsyncDirectory(dirname(path));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
await handle?.close().catch(() => {});
|
|
90
|
+
await unlink(temp).catch(() => {});
|
|
91
|
+
if (error instanceof WaitCheckpointError) throw error;
|
|
92
|
+
throw new WaitCheckpointError("wait_checkpoint_write_failed", `could not persist wait checkpoint: ${error?.message ?? error}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function processStartedAt(pid) {
|
|
97
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
98
|
+
try {
|
|
99
|
+
const { stdout } = await execFile("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
100
|
+
timeout: 1_000,
|
|
101
|
+
maxBuffer: LOCK_MAX_BYTES,
|
|
102
|
+
});
|
|
103
|
+
const value = String(stdout).trim();
|
|
104
|
+
return value || null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function installFenceExitHook() {
|
|
111
|
+
if (installedFenceExitHook) return;
|
|
112
|
+
installedFenceExitHook = true;
|
|
113
|
+
process.once("exit", () => {
|
|
114
|
+
for (const [path, token] of heldFenceLocks) {
|
|
115
|
+
try {
|
|
116
|
+
const lock = JSON.parse(readFileSync(path, "utf8"));
|
|
117
|
+
if (lock?.token === token) unlinkSync(path);
|
|
118
|
+
} catch { /* A SIGKILL or a replaced lock is intentionally left alone. */ }
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createWaitCheckpointStore({ root = process.cwd(), now = () => new Date() } = {}) {
|
|
124
|
+
const worktree = resolve(root);
|
|
125
|
+
const directory = join(worktree, ".botbuddy", "waits");
|
|
126
|
+
const pathFor = (id) => {
|
|
127
|
+
const path = join(directory, `${safeId(id)}.json`);
|
|
128
|
+
assertUnderRoot(directory, path);
|
|
129
|
+
return path;
|
|
130
|
+
};
|
|
131
|
+
const lockPathFor = (id) => {
|
|
132
|
+
const path = join(directory, `${safeId(id)}.lock`);
|
|
133
|
+
assertUnderRoot(directory, path);
|
|
134
|
+
return path;
|
|
135
|
+
};
|
|
136
|
+
const recoveryPathFor = (id) => {
|
|
137
|
+
const path = join(directory, `${safeId(id)}.recovery`);
|
|
138
|
+
assertUnderRoot(directory, path);
|
|
139
|
+
return path;
|
|
140
|
+
};
|
|
141
|
+
const ensureDirectory = async () => {
|
|
142
|
+
try { await mkdir(directory, { recursive: true, mode: 0o700 }); await chmod(directory, 0o700); }
|
|
143
|
+
catch (error) { throw new WaitCheckpointError("wait_checkpoint_write_failed", `could not create wait state directory: ${error?.message ?? error}`); }
|
|
144
|
+
};
|
|
145
|
+
const read = async (id) => {
|
|
146
|
+
const path = pathFor(id);
|
|
147
|
+
let stat;
|
|
148
|
+
try { stat = await lstat(path); }
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error?.code === "ENOENT") throw new WaitCheckpointError("wait_checkpoint_missing", `saved wait ${id} was not found`);
|
|
151
|
+
throw new WaitCheckpointError("wait_checkpoint_invalid", `could not inspect saved wait: ${error?.message ?? error}`);
|
|
152
|
+
}
|
|
153
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > CHECKPOINT_MAX_BYTES) {
|
|
154
|
+
throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint is not a regular bounded JSON file");
|
|
155
|
+
}
|
|
156
|
+
let parsed;
|
|
157
|
+
try { parsed = JSON.parse(await readFile(path, "utf8")); }
|
|
158
|
+
catch { throw new WaitCheckpointError("wait_checkpoint_invalid", "checkpoint is not valid JSON"); }
|
|
159
|
+
return validateCheckpoint(parsed);
|
|
160
|
+
};
|
|
161
|
+
const write = async (value) => {
|
|
162
|
+
validateCheckpoint(value);
|
|
163
|
+
await ensureDirectory();
|
|
164
|
+
await durableJson(pathFor(value.local_wait_id), value);
|
|
165
|
+
return value;
|
|
166
|
+
};
|
|
167
|
+
const readLock = async (path) => {
|
|
168
|
+
let stat;
|
|
169
|
+
try { stat = await lstat(path); }
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error?.code === "ENOENT") return null;
|
|
172
|
+
throw new WaitCheckpointError("wait_owner_unknown", `could not inspect wait owner: ${error?.message ?? error}`);
|
|
173
|
+
}
|
|
174
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > LOCK_MAX_BYTES) {
|
|
175
|
+
throw new WaitCheckpointError("wait_owner_unknown", "wait ownership fence is invalid");
|
|
176
|
+
}
|
|
177
|
+
let lock;
|
|
178
|
+
try { lock = JSON.parse(await readFile(path, "utf8")); }
|
|
179
|
+
catch { throw new WaitCheckpointError("wait_owner_unknown", "wait ownership fence is malformed"); }
|
|
180
|
+
if (!lock || typeof lock.token !== "string" || !Number.isSafeInteger(lock.pid) ||
|
|
181
|
+
typeof lock.process_started_at !== "string" || !lock.process_started_at) {
|
|
182
|
+
throw new WaitCheckpointError("wait_owner_unknown", "wait ownership fence is malformed");
|
|
183
|
+
}
|
|
184
|
+
return lock;
|
|
185
|
+
};
|
|
186
|
+
const lockIsLive = async (lock) => {
|
|
187
|
+
try { process.kill(lock.pid, 0); }
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error?.code === "ESRCH") return false;
|
|
190
|
+
return true; // EPERM is deliberately conservative.
|
|
191
|
+
}
|
|
192
|
+
const startedAt = await processStartedAt(lock.pid);
|
|
193
|
+
// A live PID without a matching process-instance identity is never ownership proof.
|
|
194
|
+
return startedAt != null && startedAt === lock.process_started_at;
|
|
195
|
+
};
|
|
196
|
+
const removeRecoveryDirectory = async (path) => {
|
|
197
|
+
await unlink(join(path, "owner.json")).catch(() => {});
|
|
198
|
+
await rmdir(path).catch(() => {});
|
|
199
|
+
};
|
|
200
|
+
const acquireRecoveryMutex = async (id, owner) => {
|
|
201
|
+
const path = recoveryPathFor(id);
|
|
202
|
+
const temp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
203
|
+
await mkdir(temp, { mode: 0o700 });
|
|
204
|
+
const token = crypto.randomUUID();
|
|
205
|
+
await durableJson(join(temp, "owner.json"), { ...owner, token });
|
|
206
|
+
const retired = [];
|
|
207
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
208
|
+
try {
|
|
209
|
+
await rename(temp, path);
|
|
210
|
+
await fsyncDirectory(directory);
|
|
211
|
+
let released = false;
|
|
212
|
+
return async () => {
|
|
213
|
+
if (released) return;
|
|
214
|
+
released = true;
|
|
215
|
+
// Retire ownership with one atomic rename. A crash while deleting the
|
|
216
|
+
// retired directory leaves the fixed mutex path free for recovery.
|
|
217
|
+
const releasedPath = `${path}.${token}.${crypto.randomUUID()}.released`;
|
|
218
|
+
await rename(path, releasedPath).catch((error) => {
|
|
219
|
+
if (error?.code !== "ENOENT") throw error;
|
|
220
|
+
});
|
|
221
|
+
await removeRecoveryDirectory(releasedPath);
|
|
222
|
+
for (const stale of retired) await removeRecoveryDirectory(stale);
|
|
223
|
+
await fsyncDirectory(directory);
|
|
224
|
+
};
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(error?.code)) {
|
|
227
|
+
await removeRecoveryDirectory(temp);
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
let current;
|
|
232
|
+
try { current = await readLock(join(path, "owner.json")); }
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (error?.code !== "wait_owner_unknown") throw error;
|
|
235
|
+
current = null;
|
|
236
|
+
}
|
|
237
|
+
if (current && await lockIsLive(current)) {
|
|
238
|
+
await removeRecoveryDirectory(temp);
|
|
239
|
+
throw new WaitCheckpointError("wait_owner_active", `saved wait ${id} recovery is already in progress`);
|
|
240
|
+
}
|
|
241
|
+
// A missing/malformed owner is also stale. The deterministic target keeps
|
|
242
|
+
// two contenders that observed the same generation from retiring a fresh
|
|
243
|
+
// mutex published after the first contender wins.
|
|
244
|
+
const stale = `${path}.${current?.token ?? "invalid"}.stale`;
|
|
245
|
+
assertUnderRoot(directory, stale);
|
|
246
|
+
try {
|
|
247
|
+
await rename(path, stale);
|
|
248
|
+
retired.push(stale);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (!["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error?.code)) throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
await removeRecoveryDirectory(temp);
|
|
254
|
+
throw new WaitCheckpointError("wait_owner_active", `saved wait ${id} recovery ownership changed`);
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
directory,
|
|
258
|
+
async create(input) {
|
|
259
|
+
const id = safeId(input.local_wait_id);
|
|
260
|
+
const timestamp = now().toISOString();
|
|
261
|
+
const checkpoint = validateCheckpoint({
|
|
262
|
+
schema_version: CHECKPOINT_SCHEMA_VERSION,
|
|
263
|
+
local_wait_id: id,
|
|
264
|
+
status: "preparing",
|
|
265
|
+
created_at: timestamp,
|
|
266
|
+
updated_at: timestamp,
|
|
267
|
+
deadline_at: input.deadline_at,
|
|
268
|
+
conditions: input.conditions,
|
|
269
|
+
mode: input.mode ?? "any",
|
|
270
|
+
...input,
|
|
271
|
+
local_wait_id: id,
|
|
272
|
+
// Scope comes from the store, never caller-supplied checkpoint metadata.
|
|
273
|
+
worktree,
|
|
274
|
+
});
|
|
275
|
+
if (Buffer.byteLength(JSON.stringify(checkpoint), "utf8") >
|
|
276
|
+
CHECKPOINT_MAX_BYTES - CHECKPOINT_TERMINAL_RESERVE_BYTES) {
|
|
277
|
+
throw new WaitCheckpointError(
|
|
278
|
+
"wait_checkpoint_write_failed",
|
|
279
|
+
"checkpoint leaves insufficient space for its terminal receipt",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return write(checkpoint);
|
|
283
|
+
},
|
|
284
|
+
read,
|
|
285
|
+
async update(id, patch) {
|
|
286
|
+
const current = await read(id);
|
|
287
|
+
const next = validateCheckpoint({ ...current, ...patch, local_wait_id: current.local_wait_id, updated_at: now().toISOString() });
|
|
288
|
+
return write(next);
|
|
289
|
+
},
|
|
290
|
+
async acknowledge(id, { cloudAckPending = false } = {}) {
|
|
291
|
+
const current = await read(id);
|
|
292
|
+
if (current.status === "acknowledged") {
|
|
293
|
+
return cloudAckPending && current.cloud_ack_pending !== true
|
|
294
|
+
? this.update(id, { cloud_ack_pending: true })
|
|
295
|
+
: current;
|
|
296
|
+
}
|
|
297
|
+
if (current.status !== "terminal") {
|
|
298
|
+
throw new WaitCheckpointError("wait_not_resumable", "only a saved terminal wait can be acknowledged");
|
|
299
|
+
}
|
|
300
|
+
// The local acknowledgement and its cloud-delivery outbox are one durable
|
|
301
|
+
// transition. A crash after hiding the checkpoint must still leave bare
|
|
302
|
+
// `bb wait` a retryable acknowledgement entry.
|
|
303
|
+
return this.update(id, {
|
|
304
|
+
status: "acknowledged",
|
|
305
|
+
acknowledged_at: now().toISOString(),
|
|
306
|
+
cloud_ack_pending: cloudAckPending,
|
|
307
|
+
});
|
|
308
|
+
},
|
|
309
|
+
async acquireFence(id) {
|
|
310
|
+
const localWaitId = safeId(id);
|
|
311
|
+
await ensureDirectory();
|
|
312
|
+
const path = lockPathFor(localWaitId);
|
|
313
|
+
const processStartedAtValue = await processStartedAt(process.pid);
|
|
314
|
+
if (!processStartedAtValue) {
|
|
315
|
+
throw new WaitCheckpointError("wait_owner_unknown", "could not establish this process identity for wait recovery");
|
|
316
|
+
}
|
|
317
|
+
const owner = {
|
|
318
|
+
pid: process.pid,
|
|
319
|
+
process_started_at: processStartedAtValue,
|
|
320
|
+
acquired_at: now().toISOString(),
|
|
321
|
+
};
|
|
322
|
+
let releaseRecovery;
|
|
323
|
+
try {
|
|
324
|
+
releaseRecovery = await acquireRecoveryMutex(localWaitId, owner);
|
|
325
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
326
|
+
const token = crypto.randomUUID();
|
|
327
|
+
const candidate = `${path}.${process.pid}.${token}.candidate`;
|
|
328
|
+
try {
|
|
329
|
+
// Write and fsync a private candidate before publishing it with an
|
|
330
|
+
// atomic no-clobber hard link. SIGKILL can leave a candidate behind,
|
|
331
|
+
// but never a partially written fence at the shared path.
|
|
332
|
+
await durableJson(candidate, { ...owner, token });
|
|
333
|
+
await link(candidate, path);
|
|
334
|
+
await unlink(candidate).catch(() => {});
|
|
335
|
+
await fsyncDirectory(directory);
|
|
336
|
+
heldFenceLocks.set(path, token);
|
|
337
|
+
installFenceExitHook();
|
|
338
|
+
let released = false;
|
|
339
|
+
return {
|
|
340
|
+
owner,
|
|
341
|
+
async release() {
|
|
342
|
+
if (released) return;
|
|
343
|
+
released = true;
|
|
344
|
+
heldFenceLocks.delete(path);
|
|
345
|
+
const current = await readLock(path);
|
|
346
|
+
if (current?.token === token) {
|
|
347
|
+
await unlink(path);
|
|
348
|
+
await fsyncDirectory(directory);
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
} catch (error) {
|
|
353
|
+
await unlink(candidate).catch(() => {});
|
|
354
|
+
if (error?.code !== "EEXIST") {
|
|
355
|
+
throw new WaitCheckpointError("wait_checkpoint_write_failed", `could not acquire wait fence: ${error?.message ?? error}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
let current;
|
|
359
|
+
try { current = await readLock(path); }
|
|
360
|
+
catch (error) {
|
|
361
|
+
if (error?.code !== "wait_owner_unknown") throw error;
|
|
362
|
+
// The recovery mutex prevents a new generation from appearing here.
|
|
363
|
+
// A malformed/partial shared fence therefore belongs to a crashed
|
|
364
|
+
// publisher and is safe to retire before the next atomic attempt.
|
|
365
|
+
await unlink(path).catch((unlinkError) => {
|
|
366
|
+
if (unlinkError?.code !== "ENOENT") throw unlinkError;
|
|
367
|
+
});
|
|
368
|
+
await fsyncDirectory(directory);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (!current) continue;
|
|
372
|
+
if (await lockIsLive(current)) {
|
|
373
|
+
throw new WaitCheckpointError("wait_owner_active", `saved wait ${localWaitId} already has a live owner`);
|
|
374
|
+
}
|
|
375
|
+
// The crash-reclaimable recovery mutex serializes this replacement, so
|
|
376
|
+
// no second resumer can unlink a fresh generation at the shared path.
|
|
377
|
+
await unlink(path).catch((error) => {
|
|
378
|
+
if (error?.code !== "ENOENT") throw error;
|
|
379
|
+
});
|
|
380
|
+
await fsyncDirectory(directory);
|
|
381
|
+
}
|
|
382
|
+
throw new WaitCheckpointError("wait_owner_active", `saved wait ${localWaitId} ownership changed while recovering`);
|
|
383
|
+
} finally {
|
|
384
|
+
await releaseRecovery?.();
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
async list({ includeInactive = false, offset = 0, limit = INVENTORY_MAX_ITEMS } = {}) {
|
|
388
|
+
let names = [];
|
|
389
|
+
try { names = (await readdir(directory)).filter((name) => name.endsWith(".json")).sort(); }
|
|
390
|
+
catch (error) { if (error?.code === "ENOENT") return { items: [], has_more: false, next_offset: null }; throw error; }
|
|
391
|
+
const entries = [];
|
|
392
|
+
for (const name of names) {
|
|
393
|
+
const id = name.slice(0, -5);
|
|
394
|
+
try {
|
|
395
|
+
const value = await read(id);
|
|
396
|
+
const pendingCancellation = value.status === "cancelled" && value.cloud_cancel_pending === true;
|
|
397
|
+
const pendingAcknowledgement = value.status === "acknowledged" && value.cloud_ack_pending === true;
|
|
398
|
+
if (includeInactive || pendingCancellation || pendingAcknowledgement ||
|
|
399
|
+
!["acknowledged", "cancelled"].includes(value.status)) entries.push(value);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
entries.push({ local_wait_id: id, status: "invalid", error: error.code ?? "wait_checkpoint_invalid" });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
entries.sort((a, b) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")));
|
|
405
|
+
const page = entries.slice(offset, offset + Math.min(Math.max(1, limit), INVENTORY_MAX_ITEMS));
|
|
406
|
+
const next = offset + page.length;
|
|
407
|
+
return { items: page, has_more: next < entries.length, next_offset: next < entries.length ? next : null };
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|