@openclaw/lobster 2026.9.8 → 2026.10.1-beta.2

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 (141) hide show
  1. package/README.md +10 -9
  2. package/dist/index.js +45 -39
  3. package/node_modules/@clawdbot/lobster/README.md +4 -0
  4. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/head.js +4 -2
  5. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/head.js.map +1 -1
  6. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js +35 -12
  7. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js.map +1 -1
  8. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js +13 -1
  9. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js.map +1 -1
  10. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/pick.js +1 -4
  11. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/pick.js.map +1 -1
  12. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/table.js +3 -5
  13. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/table.js.map +1 -1
  14. package/node_modules/@clawdbot/lobster/package.json +7 -7
  15. package/package.json +5 -5
  16. package/node_modules/@clawdbot/lobster/dist/test/approval_id.test.js +0 -171
  17. package/node_modules/@clawdbot/lobster/dist/test/approval_id.test.js.map +0 -1
  18. package/node_modules/@clawdbot/lobster/dist/test/approve_preview.test.js +0 -38
  19. package/node_modules/@clawdbot/lobster/dist/test/approve_preview.test.js.map +0 -1
  20. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js +0 -25
  21. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js.map +0 -1
  22. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js +0 -5171
  23. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js.map +0 -1
  24. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js +0 -146
  25. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js.map +0 -1
  26. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke_legacy.test.js +0 -63
  27. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke_legacy.test.js.map +0 -1
  28. package/node_modules/@clawdbot/lobster/dist/test/cli_run_errors.test.js +0 -53
  29. package/node_modules/@clawdbot/lobster/dist/test/cli_run_errors.test.js.map +0 -1
  30. package/node_modules/@clawdbot/lobster/dist/test/cli_run_file_args_json.test.js +0 -27
  31. package/node_modules/@clawdbot/lobster/dist/test/cli_run_file_args_json.test.js.map +0 -1
  32. package/node_modules/@clawdbot/lobster/dist/test/command_arguments.test.js +0 -82
  33. package/node_modules/@clawdbot/lobster/dist/test/command_arguments.test.js.map +0 -1
  34. package/node_modules/@clawdbot/lobster/dist/test/commands_list.test.js +0 -44
  35. package/node_modules/@clawdbot/lobster/dist/test/commands_list.test.js.map +0 -1
  36. package/node_modules/@clawdbot/lobster/dist/test/condition_comparison.test.js +0 -127
  37. package/node_modules/@clawdbot/lobster/dist/test/condition_comparison.test.js.map +0 -1
  38. package/node_modules/@clawdbot/lobster/dist/test/core_tool_runtime.test.js +0 -160
  39. package/node_modules/@clawdbot/lobster/dist/test/core_tool_runtime.test.js.map +0 -1
  40. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js +0 -1993
  41. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js.map +0 -1
  42. package/node_modules/@clawdbot/lobster/dist/test/dedupe.test.js +0 -48
  43. package/node_modules/@clawdbot/lobster/dist/test/dedupe.test.js.map +0 -1
  44. package/node_modules/@clawdbot/lobster/dist/test/diff_last.test.js +0 -70
  45. package/node_modules/@clawdbot/lobster/dist/test/diff_last.test.js.map +0 -1
  46. package/node_modules/@clawdbot/lobster/dist/test/doctor.test.js +0 -19
  47. package/node_modules/@clawdbot/lobster/dist/test/doctor.test.js.map +0 -1
  48. package/node_modules/@clawdbot/lobster/dist/test/dry_run.test.js +0 -502
  49. package/node_modules/@clawdbot/lobster/dist/test/dry_run.test.js.map +0 -1
  50. package/node_modules/@clawdbot/lobster/dist/test/email_triage.test.js +0 -296
  51. package/node_modules/@clawdbot/lobster/dist/test/email_triage.test.js.map +0 -1
  52. package/node_modules/@clawdbot/lobster/dist/test/exec_stdin.test.js +0 -43
  53. package/node_modules/@clawdbot/lobster/dist/test/exec_stdin.test.js.map +0 -1
  54. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js +0 -482
  55. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js.map +0 -1
  56. package/node_modules/@clawdbot/lobster/dist/test/github_pr_notify_format.test.js +0 -19
  57. package/node_modules/@clawdbot/lobster/dist/test/github_pr_notify_format.test.js.map +0 -1
  58. package/node_modules/@clawdbot/lobster/dist/test/github_pr_summary.test.js +0 -41
  59. package/node_modules/@clawdbot/lobster/dist/test/github_pr_summary.test.js.map +0 -1
  60. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js +0 -176
  61. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js.map +0 -1
  62. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js +0 -100
  63. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js.map +0 -1
  64. package/node_modules/@clawdbot/lobster/dist/test/group_by.test.js +0 -43
  65. package/node_modules/@clawdbot/lobster/dist/test/group_by.test.js.map +0 -1
  66. package/node_modules/@clawdbot/lobster/dist/test/helpers/node_shell_fixture.js +0 -17
  67. package/node_modules/@clawdbot/lobster/dist/test/helpers/node_shell_fixture.js.map +0 -1
  68. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js +0 -19
  69. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js.map +0 -1
  70. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js +0 -318
  71. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js.map +0 -1
  72. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js +0 -1113
  73. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js.map +0 -1
  74. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js +0 -519
  75. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js.map +0 -1
  76. package/node_modules/@clawdbot/lobster/dist/test/map.test.js +0 -41
  77. package/node_modules/@clawdbot/lobster/dist/test/map.test.js.map +0 -1
  78. package/node_modules/@clawdbot/lobster/dist/test/multi_approval_resume.test.js +0 -48
  79. package/node_modules/@clawdbot/lobster/dist/test/multi_approval_resume.test.js.map +0 -1
  80. package/node_modules/@clawdbot/lobster/dist/test/on_error.test.js +0 -151
  81. package/node_modules/@clawdbot/lobster/dist/test/on_error.test.js.map +0 -1
  82. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js +0 -169
  83. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js.map +0 -1
  84. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js +0 -96
  85. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js.map +0 -1
  86. package/node_modules/@clawdbot/lobster/dist/test/openclaw_invoke_alias.test.js +0 -13
  87. package/node_modules/@clawdbot/lobster/dist/test/openclaw_invoke_alias.test.js.map +0 -1
  88. package/node_modules/@clawdbot/lobster/dist/test/parallel.test.js +0 -184
  89. package/node_modules/@clawdbot/lobster/dist/test/parallel.test.js.map +0 -1
  90. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js +0 -96
  91. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js.map +0 -1
  92. package/node_modules/@clawdbot/lobster/dist/test/pipeline_resume_state.test.js +0 -65
  93. package/node_modules/@clawdbot/lobster/dist/test/pipeline_resume_state.test.js.map +0 -1
  94. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js +0 -175
  95. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js.map +0 -1
  96. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js +0 -79
  97. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js.map +0 -1
  98. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js +0 -39
  99. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js.map +0 -1
  100. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js +0 -946
  101. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js.map +0 -1
  102. package/node_modules/@clawdbot/lobster/dist/test/resume.test.js +0 -82
  103. package/node_modules/@clawdbot/lobster/dist/test/resume.test.js.map +0 -1
  104. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js +0 -248
  105. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js.map +0 -1
  106. package/node_modules/@clawdbot/lobster/dist/test/sdk_lobster.test.js +0 -177
  107. package/node_modules/@clawdbot/lobster/dist/test/sdk_lobster.test.js.map +0 -1
  108. package/node_modules/@clawdbot/lobster/dist/test/shell.test.js +0 -31
  109. package/node_modules/@clawdbot/lobster/dist/test/shell.test.js.map +0 -1
  110. package/node_modules/@clawdbot/lobster/dist/test/sort.test.js +0 -51
  111. package/node_modules/@clawdbot/lobster/dist/test/sort.test.js.map +0 -1
  112. package/node_modules/@clawdbot/lobster/dist/test/state.test.js +0 -919
  113. package/node_modules/@clawdbot/lobster/dist/test/state.test.js.map +0 -1
  114. package/node_modules/@clawdbot/lobster/dist/test/step_retry.test.js +0 -254
  115. package/node_modules/@clawdbot/lobster/dist/test/step_retry.test.js.map +0 -1
  116. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js +0 -44
  117. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js.map +0 -1
  118. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js +0 -220
  119. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js.map +0 -1
  120. package/node_modules/@clawdbot/lobster/dist/test/template.test.js +0 -46
  121. package/node_modules/@clawdbot/lobster/dist/test/template.test.js.map +0 -1
  122. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js +0 -144
  123. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js.map +0 -1
  124. package/node_modules/@clawdbot/lobster/dist/test/tool_envelope_version.test.js +0 -15
  125. package/node_modules/@clawdbot/lobster/dist/test/tool_envelope_version.test.js.map +0 -1
  126. package/node_modules/@clawdbot/lobster/dist/test/tool_mode.test.js +0 -83
  127. package/node_modules/@clawdbot/lobster/dist/test/tool_mode.test.js.map +0 -1
  128. package/node_modules/@clawdbot/lobster/dist/test/validation.test.js +0 -28
  129. package/node_modules/@clawdbot/lobster/dist/test/validation.test.js.map +0 -1
  130. package/node_modules/@clawdbot/lobster/dist/test/where.test.js +0 -94
  131. package/node_modules/@clawdbot/lobster/dist/test/where.test.js.map +0 -1
  132. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js +0 -43
  133. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js.map +0 -1
  134. package/node_modules/@clawdbot/lobster/dist/test/workflow_composition.test.js +0 -238
  135. package/node_modules/@clawdbot/lobster/dist/test/workflow_composition.test.js.map +0 -1
  136. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js +0 -2045
  137. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js.map +0 -1
  138. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js +0 -107
  139. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js.map +0 -1
  140. package/node_modules/@clawdbot/lobster/dist/test/workflows.test.js +0 -32
  141. package/node_modules/@clawdbot/lobster/dist/test/workflows.test.js.map +0 -1
@@ -1,919 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { mkdtempSync, promises as fsp } from "node:fs";
6
- import { createDefaultRegistry } from "../src/commands/registry.js";
7
- import { runPipeline } from "../src/runtime.js";
8
- import { diffLast, diffAndStoreValue } from "../src/sdk/primitives/diff.js";
9
- import { stateSet, readState, writeState } from "../src/sdk/primitives/state.js";
10
- import { createApprovalIndex, consumeResumeState, deleteResumeStateWithRollback, diffAndStore, keyToPath, withFileLock, ensureDirectory, stripExtendedLengthPrefix, writeStateJson, readStateJsonWithLock as readStateJson, writeFileAtomic, writeFileAtomicExclusive, } from "../src/state/store.js";
11
- function streamOf(items) {
12
- return (async function* () {
13
- for (const item of items)
14
- yield item;
15
- })();
16
- }
17
- test("state key boundary trimming preserves normalized filenames", () => {
18
- const tmp = path.join(os.tmpdir(), "lobster-state");
19
- for (const [key, filename] of [
20
- ["_Demo KEY_", "demo_key.json"],
21
- ["___Demo KEY___", "demo_key.json"],
22
- [" Demo KEY ", "demo_key.json"],
23
- ["-Demo KEY-", "-demo_key-.json"],
24
- [".Demo KEY.", ".demo_key..json"],
25
- ]) {
26
- assert.equal(keyToPath(tmp, key), path.join(tmp, filename));
27
- }
28
- assert.throws(() => keyToPath(tmp, "_"), /state key is empty\/invalid/);
29
- assert.throws(() => keyToPath(tmp, "___"), /state key is empty\/invalid/);
30
- });
31
- test("state.set writes and state.get reads", async () => {
32
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-"));
33
- const registry = createDefaultRegistry();
34
- const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
35
- // write
36
- const setCmd = registry.get("state.set");
37
- await setCmd.run({
38
- input: streamOf([{ a: 1 }]),
39
- args: { _: ["demo-key"] },
40
- ctx: {
41
- stdin: process.stdin,
42
- stdout: process.stdout,
43
- stderr: process.stderr,
44
- env,
45
- registry,
46
- mode: "tool",
47
- render: { json() { }, lines() { } },
48
- },
49
- });
50
- // read
51
- const getCmd = registry.get("state.get");
52
- const res = await getCmd.run({
53
- input: streamOf([]),
54
- args: { _: ["demo-key"] },
55
- ctx: {
56
- stdin: process.stdin,
57
- stdout: process.stdout,
58
- stderr: process.stderr,
59
- env,
60
- registry,
61
- mode: "tool",
62
- render: { json() { }, lines() { } },
63
- },
64
- });
65
- const items = [];
66
- for await (const it of res.output)
67
- items.push(it);
68
- assert.deepEqual(items, [{ a: 1 }]);
69
- });
70
- test("state.get returns null for missing key", async () => {
71
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-"));
72
- const registry = createDefaultRegistry();
73
- const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
74
- const output = await runPipeline({
75
- pipeline: [{ name: "state.get", args: { _: ["missing"] }, raw: "state.get missing" }],
76
- registry,
77
- input: [],
78
- stdin: process.stdin,
79
- stdout: process.stdout,
80
- stderr: process.stderr,
81
- env,
82
- mode: "tool",
83
- });
84
- assert.deepEqual(output.items, [null]);
85
- });
86
- test("ordinary state reads work when creating a coordination lock is forbidden", async () => {
87
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-readonly-state-"));
88
- const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
89
- const key = "demo";
90
- const value = { readable: true };
91
- const statePath = keyToPath(tmp, key);
92
- const lockPath = `${statePath}.lock`;
93
- const originalMkdir = fsp.mkdir;
94
- await fsp.writeFile(statePath, JSON.stringify(value), "utf8");
95
- Object.defineProperty(fsp, "mkdir", {
96
- configurable: true,
97
- writable: true,
98
- async value(filePath, options) {
99
- if (String(filePath) === lockPath) {
100
- throw Object.assign(new Error("read-only state directory"), { code: "EACCES" });
101
- }
102
- return originalMkdir(filePath, options);
103
- },
104
- });
105
- try {
106
- assert.deepEqual(await readState(key, { env }), value);
107
- const registry = createDefaultRegistry();
108
- const output = await runPipeline({
109
- pipeline: [{ name: "state.get", args: { _: [key] }, raw: `state.get ${key}` }],
110
- registry,
111
- input: [],
112
- stdin: process.stdin,
113
- stdout: process.stdout,
114
- stderr: process.stderr,
115
- env,
116
- mode: "tool",
117
- });
118
- assert.deepEqual(output.items, [value]);
119
- }
120
- finally {
121
- Object.defineProperty(fsp, "mkdir", {
122
- configurable: true,
123
- writable: true,
124
- value: originalMkdir,
125
- });
126
- await fsp.rm(tmp, { recursive: true, force: true });
127
- }
128
- });
129
- // --- Atomic-write behavior proofs (issues #108, #109) ---
130
- //
131
- // Plain fsp.writeFile truncates the target before writing, so a concurrent
132
- // reader (or a crash mid-write) can observe an empty/partial file and fail to
133
- // JSON.parse it. These tests drive many large writes while reading in parallel
134
- // and assert the reader NEVER sees a truncated value. They fail against the
135
- // pre-fix non-atomic writeFile and pass with writeFileAtomic (stage + rename).
136
- test("writeStateJson is atomic: concurrent reads never observe truncated state (#108)", async () => {
137
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-store-"));
138
- const env = { LOBSTER_STATE_DIR: tmp };
139
- const key = "pipeline-resume";
140
- const payload = "x".repeat(256 * 1024); // large enough that writeFile is not instantaneous
141
- await writeStateJson({ env, key, value: { payload, n: 0 } });
142
- let readErrors = 0;
143
- let partialReads = 0;
144
- const reader = (async () => {
145
- for (let i = 0; i < 500; i++) {
146
- try {
147
- const v = await readStateJson({ env, key });
148
- if (!v || v.payload !== payload)
149
- partialReads++;
150
- }
151
- catch {
152
- readErrors++; // JSON.parse on truncated content throws SyntaxError
153
- }
154
- }
155
- })();
156
- const writer = (async () => {
157
- for (let n = 1; n <= 150; n++) {
158
- await writeStateJson({ env, key, value: { payload, n } });
159
- }
160
- })();
161
- await Promise.all([reader, writer]);
162
- assert.equal(readErrors, 0, "reader must never hit a parse/IO error mid-write");
163
- assert.equal(partialReads, 0, "reader must never observe truncated/empty state");
164
- const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
165
- assert.deepEqual(leftovers, [], "atomic write must not leave temp files behind");
166
- });
167
- test("writeFileAtomic creates private files and preserves existing modes", async () => {
168
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-mode-"));
169
- const freshPath = path.join(tmp, "fresh.json");
170
- const existingPath = path.join(tmp, "existing.json");
171
- await writeFileAtomic(freshPath, '{"ok":true}\n');
172
- assert.equal((await fsp.stat(freshPath)).mode & 0o777, 0o600);
173
- await fsp.writeFile(existingPath, '{"old":true}\n', { mode: 0o640 });
174
- await fsp.chmod(existingPath, 0o640);
175
- await writeFileAtomic(existingPath, '{"ok":true}\n');
176
- assert.equal((await fsp.stat(existingPath)).mode & 0o777, 0o640);
177
- });
178
- test("writeFileAtomic removes temp files when replacement fails", async () => {
179
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-cleanup-"));
180
- const targetDir = path.join(tmp, "state.json");
181
- await fsp.mkdir(targetDir);
182
- await assert.rejects(() => writeFileAtomic(targetDir, '{"ok":true}\n'));
183
- const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
184
- assert.deepEqual(leftovers, []);
185
- });
186
- test("writeFileAtomic leaves existing target untouched when publish fails", async () => {
187
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-fault-"));
188
- const target = path.join(tmp, "state.json");
189
- await fsp.writeFile(target, '{"old":true}\n', { mode: 0o600 });
190
- const fault = Object.assign(new Error("rename failed"), { code: "EIO" });
191
- await assert.rejects(() => writeFileAtomic(target, '{"new":true}\n', {
192
- async renameFile() {
193
- throw fault;
194
- },
195
- }), (err) => err?.code === "EIO");
196
- assert.equal(await fsp.readFile(target, "utf8"), '{"old":true}\n');
197
- const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
198
- assert.deepEqual(leftovers, []);
199
- });
200
- test("writeFileAtomic propagates parent directory sync failures", async () => {
201
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-dir-sync-"));
202
- const target = path.join(tmp, "state.json");
203
- const fault = Object.assign(new Error("dir sync failed"), { code: "EIO" });
204
- await assert.rejects(() => writeFileAtomic(target, '{"ok":true}\n', {
205
- async syncParentDir() {
206
- throw fault;
207
- },
208
- }), (err) => err?.code === "EIO");
209
- assert.equal(await fsp.readFile(target, "utf8"), '{"ok":true}\n');
210
- const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
211
- assert.deepEqual(leftovers, []);
212
- });
213
- test("consumeResumeState restores a published marker when parent sync fails", async () => {
214
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-consume-dir-sync-"));
215
- const env = { LOBSTER_STATE_DIR: tmp };
216
- const expectedState = { haltType: "approval_request", pipeline: [] };
217
- const fault = Object.assign(new Error("dir sync failed after resume claim"), { code: "EIO" });
218
- const originalOpen = fsp.open;
219
- let failNextDirectorySync = true;
220
- await writeStateJson({ env, key: "resume", value: expectedState });
221
- Object.defineProperty(fsp, "open", {
222
- configurable: true,
223
- writable: true,
224
- async value(...args) {
225
- const handle = await originalOpen(...args);
226
- if (failNextDirectorySync && String(args[0]) === tmp && args[1] === "r") {
227
- failNextDirectorySync = false;
228
- return new Proxy(handle, {
229
- get(target, property) {
230
- if (property === "sync")
231
- return async () => Promise.reject(fault);
232
- const value = Reflect.get(target, property);
233
- return typeof value === "function" ? value.bind(target) : value;
234
- },
235
- });
236
- }
237
- return handle;
238
- },
239
- });
240
- try {
241
- await assert.rejects(() => consumeResumeState({ env, key: "resume", expectedState }), (err) => err?.code === "EIO");
242
- assert.deepEqual(await readStateJson({ env, key: "resume" }), expectedState);
243
- }
244
- finally {
245
- Object.defineProperty(fsp, "open", {
246
- configurable: true,
247
- writable: true,
248
- value: originalOpen,
249
- });
250
- }
251
- });
252
- test("deleteResumeStateWithRollback restores a published marker when parent sync fails", async () => {
253
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-delete-resume-dir-sync-"));
254
- const env = { LOBSTER_STATE_DIR: tmp };
255
- const expectedState = { haltType: "input_request", pipeline: [] };
256
- const fault = Object.assign(new Error("dir sync failed after terminal resume claim"), {
257
- code: "EIO",
258
- });
259
- const originalOpen = fsp.open;
260
- let failNextDirectorySync = true;
261
- await writeStateJson({ env, key: "resume", value: expectedState });
262
- Object.defineProperty(fsp, "open", {
263
- configurable: true,
264
- writable: true,
265
- async value(...args) {
266
- const handle = await originalOpen(...args);
267
- if (failNextDirectorySync && String(args[0]) === tmp && args[1] === "r") {
268
- failNextDirectorySync = false;
269
- return new Proxy(handle, {
270
- get(target, property) {
271
- if (property === "sync")
272
- return async () => Promise.reject(fault);
273
- const value = Reflect.get(target, property);
274
- return typeof value === "function" ? value.bind(target) : value;
275
- },
276
- });
277
- }
278
- return handle;
279
- },
280
- });
281
- try {
282
- await assert.rejects(() => deleteResumeStateWithRollback({ env, key: "resume", expectedState }), (err) => err?.code === "EIO");
283
- assert.deepEqual(await readStateJson({ env, key: "resume" }), expectedState);
284
- }
285
- finally {
286
- Object.defineProperty(fsp, "open", {
287
- configurable: true,
288
- writable: true,
289
- value: originalOpen,
290
- });
291
- }
292
- });
293
- test("readStateJson surfaces malformed authoritative state", async () => {
294
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-corrupt-"));
295
- const env = { LOBSTER_STATE_DIR: tmp };
296
- await fsp.writeFile(path.join(tmp, "resume.json"), '{"partial"', "utf8");
297
- await assert.rejects(() => readStateJson({ env, key: "resume" }), SyntaxError);
298
- });
299
- test("writeFileAtomicExclusive creates private files without replacing existing targets", async () => {
300
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-exclusive-"));
301
- const target = path.join(tmp, "approval_deadbeef.json");
302
- await writeFileAtomicExclusive(target, '{"stateKey":"original"}\n');
303
- assert.equal((await fsp.stat(target)).mode & 0o777, 0o600);
304
- await assert.rejects(() => writeFileAtomicExclusive(target, '{"stateKey":"replacement"}\n'), (err) => err?.code === "EEXIST");
305
- assert.equal(await fsp.readFile(target, "utf8"), '{"stateKey":"original"}\n');
306
- const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
307
- assert.deepEqual(leftovers, []);
308
- });
309
- test("writeFileAtomicExclusive removes temp link before final directory sync", async () => {
310
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-exclusive-sync-order-"));
311
- const target = path.join(tmp, "approval_deadbeef.json");
312
- let filesAtSync = [];
313
- await writeFileAtomicExclusive(target, '{"stateKey":"original"}\n', {
314
- async syncParentDir() {
315
- filesAtSync = await fsp.readdir(tmp);
316
- },
317
- });
318
- assert.equal(await fsp.readFile(target, "utf8"), '{"stateKey":"original"}\n');
319
- assert.ok(filesAtSync.includes("approval_deadbeef.json"));
320
- assert.deepEqual(filesAtSync.filter((file) => file.includes(".tmp")), []);
321
- });
322
- test("writeFileAtomicExclusive rejects unsupported hard links without a partial target", async () => {
323
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-exclusive-unsupported-"));
324
- const target = path.join(tmp, "approval_deadbeef.json");
325
- const unsupported = Object.assign(new Error("operation not supported"), { code: "ENOTSUP" });
326
- const options = {
327
- async linkFile() {
328
- throw unsupported;
329
- },
330
- };
331
- await assert.rejects(() => writeFileAtomicExclusive(target, '{"stateKey":"original"}\n', options), (err) => err?.code === "ENOTSUP");
332
- await assert.rejects(() => fsp.stat(target), (err) => err?.code === "ENOENT");
333
- const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
334
- assert.deepEqual(leftovers, []);
335
- });
336
- test("writeFileAtomicExclusive removes published target when parent directory sync fails", async () => {
337
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-exclusive-dir-sync-"));
338
- const target = path.join(tmp, "approval_deadbeef.json");
339
- const fault = Object.assign(new Error("dir sync failed"), { code: "EIO" });
340
- await assert.rejects(() => writeFileAtomicExclusive(target, '{"stateKey":"original"}\n', {
341
- async syncParentDir() {
342
- throw fault;
343
- },
344
- }), (err) => err?.code === "EIO");
345
- await assert.rejects(() => fsp.stat(target), (err) => err?.code === "ENOENT");
346
- const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
347
- assert.deepEqual(leftovers, []);
348
- });
349
- test("createApprovalIndex omits short ID when atomic exclusive publish is unsupported", async () => {
350
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-approval-index-unsupported-"));
351
- const env = { LOBSTER_STATE_DIR: tmp };
352
- const unsupported = Object.assign(new Error("operation not supported"), { code: "ENOTSUP" });
353
- const approvalId = await createApprovalIndex({
354
- env,
355
- stateKey: "workflow_resume_1",
356
- options: {
357
- async linkFile() {
358
- throw unsupported;
359
- },
360
- },
361
- });
362
- assert.equal(approvalId, null);
363
- const files = await fsp.readdir(tmp);
364
- assert.deepEqual(files, []);
365
- });
366
- test("createApprovalIndex omits short ID when approval index durability fails", async () => {
367
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-approval-index-sync-fails-"));
368
- const env = { LOBSTER_STATE_DIR: tmp };
369
- const fault = Object.assign(new Error("dir sync failed"), { code: "EIO" });
370
- const approvalId = await createApprovalIndex({
371
- env,
372
- stateKey: "workflow_resume_1",
373
- options: {
374
- async syncParentDir() {
375
- throw fault;
376
- },
377
- },
378
- });
379
- assert.equal(approvalId, null);
380
- const files = await fsp.readdir(tmp);
381
- assert.deepEqual(files, []);
382
- });
383
- test("diffAndStore treats corrupt previous state as a miss and rewrites atomically (#112)", async () => {
384
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-corrupt-"));
385
- const env = { LOBSTER_STATE_DIR: tmp };
386
- await fsp.writeFile(path.join(tmp, "snapshot.json"), '{"partial"', "utf8");
387
- const result = await diffAndStore({ env, key: "snapshot", value: { ok: true } });
388
- assert.equal(result.before, null);
389
- assert.equal(result.changed, true);
390
- assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { ok: true });
391
- });
392
- test("diffAndStore rolls back a state publication when its parent-directory sync fails", async () => {
393
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-dir-sync-"));
394
- const env = { LOBSTER_STATE_DIR: tmp };
395
- await writeStateJson({ env, key: "snapshot", value: { version: "before" } });
396
- const fault = Object.assign(new Error("dir sync failed after state publication"), {
397
- code: "EIO",
398
- });
399
- await assert.rejects(() => diffAndStore({
400
- env,
401
- key: "snapshot",
402
- value: { version: "after" },
403
- atomicWriteOptions: {
404
- async syncParentDir() {
405
- throw fault;
406
- },
407
- },
408
- }), (err) => err?.code === "EIO");
409
- assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "before" });
410
- await fsp.rm(tmp, { recursive: true, force: true });
411
- });
412
- test("diffAndStore does not publish a snapshot after cancellation before atomic replace", async () => {
413
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-cancel-publish-"));
414
- const env = { LOBSTER_STATE_DIR: tmp };
415
- await writeStateJson({ env, key: "snapshot", value: { version: "before" } });
416
- const controller = new AbortController();
417
- const signal = controller.signal;
418
- const throwIfAborted = signal.throwIfAborted.bind(signal);
419
- let signalChecks = 0;
420
- Object.defineProperty(signal, "throwIfAborted", {
421
- value() {
422
- signalChecks += 1;
423
- if (signalChecks === 2)
424
- controller.abort(new Error("abort before state publish"));
425
- throwIfAborted();
426
- },
427
- });
428
- await assert.rejects(() => diffAndStore({ env, key: "snapshot", value: { version: "after" }, signal }), /abort before state publish/);
429
- assert.equal(signalChecks, 2);
430
- assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "before" });
431
- const leftovers = (await fsp.readdir(tmp)).filter((file) => file.includes(".tmp"));
432
- assert.deepEqual(leftovers, []);
433
- });
434
- test("diffAndStore restores the previous snapshot when cancellation arrives during atomic rename", async () => {
435
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-cancel-rename-"));
436
- const env = { LOBSTER_STATE_DIR: tmp };
437
- await writeStateJson({ env, key: "snapshot", value: { version: "before" } });
438
- const controller = new AbortController();
439
- await assert.rejects(() => diffAndStore({
440
- env,
441
- key: "snapshot",
442
- value: { version: "after" },
443
- signal: controller.signal,
444
- atomicWriteOptions: {
445
- async renameFile(from, to) {
446
- await fsp.rename(from, to);
447
- controller.abort(new Error("abort during atomic rename"));
448
- },
449
- },
450
- }), /abort during atomic rename/);
451
- assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "before" });
452
- const leftovers = (await fsp.readdir(tmp)).filter((file) => file.includes(".tmp"));
453
- assert.deepEqual(leftovers, []);
454
- });
455
- test("diffAndStore removes a newly published snapshot when cancellation arrives during atomic rename", async () => {
456
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-cancel-new-rename-"));
457
- const env = { LOBSTER_STATE_DIR: tmp };
458
- const controller = new AbortController();
459
- await assert.rejects(() => diffAndStore({
460
- env,
461
- key: "snapshot",
462
- value: { version: "after" },
463
- signal: controller.signal,
464
- atomicWriteOptions: {
465
- async renameFile(from, to) {
466
- await fsp.rename(from, to);
467
- controller.abort(new Error("abort during initial atomic rename"));
468
- },
469
- },
470
- }), /abort during initial atomic rename/);
471
- assert.equal(await readStateJson({ env, key: "snapshot" }), null);
472
- const leftovers = (await fsp.readdir(tmp)).filter((file) => file.includes(".tmp"));
473
- assert.deepEqual(leftovers, []);
474
- });
475
- test("diffAndStore restores an existing null snapshot when cancellation arrives during atomic rename", async () => {
476
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-cancel-null-rename-"));
477
- const env = { LOBSTER_STATE_DIR: tmp };
478
- await writeStateJson({ env, key: "snapshot", value: null });
479
- const snapshotPath = keyToPath(tmp, "snapshot");
480
- const controller = new AbortController();
481
- await assert.rejects(() => diffAndStore({
482
- env,
483
- key: "snapshot",
484
- value: { version: "after" },
485
- signal: controller.signal,
486
- atomicWriteOptions: {
487
- async renameFile(from, to) {
488
- await fsp.rename(from, to);
489
- controller.abort(new Error("abort during null snapshot rename"));
490
- },
491
- },
492
- }), /abort during null snapshot rename/);
493
- assert.equal(await readStateJson({ env, key: "snapshot" }), null);
494
- assert.equal(await fsp.readFile(snapshotPath, "utf8"), "null\n");
495
- });
496
- test("diffAndStore serializes cancellation rollback before a concurrent snapshot update", async () => {
497
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-cancel-concurrent-"));
498
- const env = { LOBSTER_STATE_DIR: tmp };
499
- await writeStateJson({ env, key: "snapshot", value: { version: "before" } });
500
- const controller = new AbortController();
501
- let publishCancelledSnapshot;
502
- const cancelledSnapshotPublished = new Promise((resolve) => {
503
- publishCancelledSnapshot = resolve;
504
- });
505
- let allowCancellation;
506
- const waitForCancellation = new Promise((resolve) => {
507
- allowCancellation = resolve;
508
- });
509
- const cancelled = diffAndStore({
510
- env,
511
- key: "snapshot",
512
- value: { version: "cancelled-A" },
513
- signal: controller.signal,
514
- atomicWriteOptions: {
515
- async renameFile(from, to) {
516
- await fsp.rename(from, to);
517
- publishCancelledSnapshot();
518
- await waitForCancellation;
519
- },
520
- },
521
- });
522
- await cancelledSnapshotPublished;
523
- let successfulSnapshotPublished = false;
524
- const successful = writeState("snapshot", { version: "successful-B" }, { env }).then(() => {
525
- successfulSnapshotPublished = true;
526
- });
527
- await new Promise((resolve) => setTimeout(resolve, 20));
528
- assert.equal(successfulSnapshotPublished, false, "the next writer must wait for rollback");
529
- controller.abort(new Error("abort during concurrent atomic rename"));
530
- allowCancellation();
531
- await assert.rejects(cancelled, /abort during concurrent atomic rename/);
532
- await successful;
533
- assert.equal(successfulSnapshotPublished, true);
534
- assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "successful-B" });
535
- const leftovers = (await fsp.readdir(tmp)).filter((file) => file.includes(".tmp") || file.endsWith(".lock"));
536
- assert.deepEqual(leftovers, []);
537
- });
538
- test("state.get waits for a diff publication to commit or roll back", async () => {
539
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-read-transaction-"));
540
- const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
541
- await writeStateJson({ env, key: "snapshot", value: { version: "before" } });
542
- let markPublished;
543
- const published = new Promise((resolve) => {
544
- markPublished = resolve;
545
- });
546
- let release;
547
- const releasePublication = new Promise((resolve) => {
548
- release = resolve;
549
- });
550
- const transaction = diffAndStore({
551
- env,
552
- key: "snapshot",
553
- value: { version: "new" },
554
- afterStore: async () => {
555
- markPublished();
556
- await releasePublication;
557
- throw new Error("paired publication failed");
558
- },
559
- });
560
- await published;
561
- const getCmd = createDefaultRegistry().get("state.get");
562
- const pendingRead = getCmd.run({
563
- input: streamOf([]),
564
- args: { _: ["snapshot"] },
565
- ctx: { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env },
566
- });
567
- const early = await Promise.race([
568
- pendingRead.then(() => "settled"),
569
- new Promise((resolve) => setTimeout(() => resolve("pending"), 25)),
570
- ]);
571
- assert.equal(early, "pending", "state.get must not expose a snapshot pending rollback");
572
- release();
573
- await assert.rejects(transaction, /paired publication failed/);
574
- const result = await pendingRead;
575
- const items = [];
576
- for await (const item of result.output)
577
- items.push(item);
578
- assert.deepEqual(items, [{ version: "before" }]);
579
- await fsp.rm(tmp, { recursive: true, force: true });
580
- });
581
- test("state.set stops waiting for a live state lock when its signal is aborted", async () => {
582
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-lock-abort-"));
583
- const env = { LOBSTER_STATE_DIR: tmp };
584
- const key = "blocked";
585
- const lockPath = `${keyToPath(tmp, key)}.lock`;
586
- await fsp.mkdir(lockPath);
587
- await fsp.writeFile(path.join(lockPath, "owner"), `${process.pid}::live-writer\n`, "utf8");
588
- const controller = new AbortController();
589
- const stateSet = createDefaultRegistry().get("state.set");
590
- const pending = stateSet.run({
591
- input: streamOf([{ value: true }]),
592
- args: { _: [key] },
593
- ctx: {
594
- stdin: process.stdin,
595
- stdout: process.stdout,
596
- stderr: process.stderr,
597
- env,
598
- signal: controller.signal,
599
- },
600
- });
601
- const completion = pending.then(() => ({ kind: "success" }), (error) => ({ kind: "error", error }));
602
- await new Promise((resolve) => setImmediate(resolve));
603
- controller.abort(new Error("state lock cancelled"));
604
- const early = await Promise.race([
605
- completion,
606
- new Promise((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 75)),
607
- ]);
608
- if (early.kind === "timeout")
609
- await fsp.rm(lockPath, { recursive: true, force: true });
610
- const settled = early.kind === "timeout" ? await completion : early;
611
- assert.notEqual(early.kind, "timeout", "state.set must not remain blocked after cancellation");
612
- assert.equal(settled.kind, "error");
613
- if (settled.kind === "error")
614
- assert.match(settled.error?.message ?? "", /state lock cancelled/);
615
- await fsp.rm(lockPath, { recursive: true, force: true });
616
- });
617
- test("diffAndStore does not reclaim a live fallback lock after a short heartbeat gap", async () => {
618
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-lock-lease-"));
619
- const env = { LOBSTER_STATE_DIR: tmp };
620
- const key = "snapshot";
621
- const lockPath = `${keyToPath(tmp, key)}.lock`;
622
- const ownerPath = path.join(lockPath, "owner");
623
- await fsp.mkdir(lockPath);
624
- await fsp.writeFile(ownerPath, `${process.pid}::live-writer\n`, "utf8");
625
- const briefGap = new Date(Date.now() - 2_000);
626
- await fsp.utimes(lockPath, briefGap, briefGap);
627
- await fsp.utimes(ownerPath, briefGap, briefGap);
628
- const controller = new AbortController();
629
- const abort = setTimeout(() => controller.abort(new Error("live fallback lock remained held")), 100);
630
- try {
631
- await assert.rejects(() => diffAndStore({ env, key, value: { version: "new" }, signal: controller.signal }), /live fallback lock remained held/);
632
- }
633
- finally {
634
- clearTimeout(abort);
635
- await fsp.rm(lockPath, { recursive: true, force: true });
636
- }
637
- assert.equal(await readStateJson({ env, key }), null);
638
- });
639
- test("diffAndStore reclaims an old lock with a malformed owner", async () => {
640
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-malformed-lock-"));
641
- const env = { LOBSTER_STATE_DIR: tmp };
642
- const lockPath = `${keyToPath(tmp, "snapshot")}.lock`;
643
- await fsp.mkdir(lockPath);
644
- await fsp.writeFile(path.join(lockPath, "owner"), "\n", "utf8");
645
- const staleAt = new Date(Date.now() - 10_000);
646
- await fsp.utimes(lockPath, staleAt, staleAt);
647
- await diffAndStore({ env, key: "snapshot", value: { version: "recovered" } });
648
- assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "recovered" });
649
- await assert.rejects(fsp.access(lockPath));
650
- });
651
- test("diffAndStore reclaims an old lock after its owner PID is reused", { skip: process.platform !== "linux" }, async () => {
652
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-reused-pid-lock-"));
653
- const env = { LOBSTER_STATE_DIR: tmp };
654
- const lockPath = `${keyToPath(tmp, "snapshot")}.lock`;
655
- const ownerPath = path.join(lockPath, "owner");
656
- await fsp.mkdir(lockPath);
657
- await fsp.writeFile(ownerPath, `${process.pid}:0:stale-owner\n`, "utf8");
658
- const staleAt = new Date(Date.now() - 10_000);
659
- await fsp.utimes(lockPath, staleAt, staleAt);
660
- await fsp.utimes(ownerPath, staleAt, staleAt);
661
- const controller = new AbortController();
662
- const timeout = setTimeout(() => controller.abort(new Error("reused lock was not reclaimed")), 250);
663
- try {
664
- await diffAndStore({
665
- env,
666
- key: "snapshot",
667
- value: { version: "recovered" },
668
- signal: controller.signal,
669
- });
670
- }
671
- finally {
672
- clearTimeout(timeout);
673
- }
674
- assert.equal(controller.signal.aborted, false);
675
- assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "recovered" });
676
- await assert.rejects(fsp.access(lockPath));
677
- });
678
- test("withFileLock does not reclaim a replacement lock when its inode is reused", async () => {
679
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-lock-replacement-"));
680
- const filePath = path.join(tmp, "snapshot.json");
681
- const lockPath = `${filePath}.lock`;
682
- await fsp.mkdir(lockPath);
683
- await fsp.writeFile(path.join(lockPath, "owner"), "2147483647::stale-owner\n", "utf8");
684
- const staleAt = new Date(Date.now() - 10_000);
685
- await fsp.utimes(lockPath, staleAt, staleAt);
686
- await fsp.utimes(path.join(lockPath, "owner"), staleAt, staleAt);
687
- const originalReadFile = fsp.readFile;
688
- const originalLstat = fsp.lstat;
689
- const staleLock = await originalLstat(lockPath);
690
- let replaced = false;
691
- let replacementActive = false;
692
- let overlap = false;
693
- let original;
694
- let replacement;
695
- let releaseReplacement;
696
- const replacementReleased = new Promise((resolve) => {
697
- releaseReplacement = resolve;
698
- });
699
- let replacementStarted;
700
- const replacementEntered = new Promise((resolve) => {
701
- replacementStarted = resolve;
702
- });
703
- let markReplacementObserved;
704
- const replacementObserved = new Promise((resolve) => {
705
- markReplacementObserved = resolve;
706
- });
707
- Object.defineProperty(fsp, "lstat", {
708
- configurable: true,
709
- writable: true,
710
- async value(...args) {
711
- const stat = await originalLstat(...args);
712
- if (replaced && String(args[0]) === lockPath) {
713
- // Linux can recycle a deleted directory's inode for its replacement.
714
- Object.assign(stat, { dev: staleLock.dev, ino: staleLock.ino });
715
- }
716
- return stat;
717
- },
718
- });
719
- Object.defineProperty(fsp, "readFile", {
720
- configurable: true,
721
- writable: true,
722
- async value(filePathArg, options) {
723
- const result = await originalReadFile(filePathArg, options);
724
- if (!replaced && String(filePathArg) === path.join(lockPath, "owner")) {
725
- replaced = true;
726
- await fsp.rm(lockPath, { recursive: true, force: true });
727
- replacement = withFileLock({
728
- filePath,
729
- task: async () => {
730
- replacementActive = true;
731
- replacementStarted();
732
- await replacementReleased;
733
- replacementActive = false;
734
- },
735
- });
736
- await replacementEntered;
737
- }
738
- else if (replacementActive && String(filePathArg) === path.join(lockPath, "owner")) {
739
- markReplacementObserved();
740
- }
741
- return result;
742
- },
743
- });
744
- try {
745
- original = withFileLock({
746
- filePath,
747
- task: async () => {
748
- if (replacementActive)
749
- overlap = true;
750
- },
751
- });
752
- await replacementEntered;
753
- await Promise.race([original, replacementObserved]);
754
- assert.equal(overlap, false, "the stale reclaimer must not enter beside the replacement");
755
- releaseReplacement();
756
- if (!replacement)
757
- throw new Error("replacement lock did not start");
758
- await Promise.all([original, replacement]);
759
- }
760
- finally {
761
- releaseReplacement();
762
- await Promise.allSettled([original, replacement]);
763
- Object.defineProperty(fsp, "lstat", {
764
- configurable: true,
765
- writable: true,
766
- value: originalLstat,
767
- });
768
- Object.defineProperty(fsp, "readFile", {
769
- configurable: true,
770
- writable: true,
771
- value: originalReadFile,
772
- });
773
- await fsp.rm(tmp, { recursive: true, force: true });
774
- }
775
- assert.equal(replaced, true);
776
- assert.equal(overlap, false);
777
- });
778
- test("withFileLock releases its key when best-effort cleanup cannot remove the detached lock", async () => {
779
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-lock-release-failure-"));
780
- const filePath = path.join(tmp, "snapshot.json");
781
- const lockPath = `${filePath}.lock`;
782
- const originalRm = fsp.rm;
783
- let failReleaseOnce = true;
784
- Object.defineProperty(fsp, "rm", {
785
- configurable: true,
786
- writable: true,
787
- async value(filePathArg, options) {
788
- if (failReleaseOnce && String(filePathArg).startsWith(lockPath)) {
789
- failReleaseOnce = false;
790
- throw Object.assign(new Error("simulated lock cleanup failure"), { code: "EIO" });
791
- }
792
- return originalRm(filePathArg, options);
793
- },
794
- });
795
- let second;
796
- try {
797
- await withFileLock({ filePath, task: async () => { } });
798
- second = withFileLock({ filePath, task: async () => "reacquired" });
799
- const settled = await Promise.race([
800
- second,
801
- new Promise((resolve) => setTimeout(() => resolve("timed out"), 500)),
802
- ]);
803
- if (settled === "timed out")
804
- await originalRm(lockPath, { recursive: true, force: true });
805
- assert.equal(settled, "reacquired", "a failed cleanup must not poison the live state lock");
806
- await second;
807
- }
808
- finally {
809
- Object.defineProperty(fsp, "rm", {
810
- configurable: true,
811
- writable: true,
812
- value: originalRm,
813
- });
814
- await originalRm(tmp, { recursive: true, force: true });
815
- }
816
- });
817
- test("SDK diff primitives treat corrupt previous state as a miss (#112)", async () => {
818
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-sdk-diff-corrupt-"));
819
- const ctx = { env: { LOBSTER_STATE_DIR: tmp } };
820
- await fsp.writeFile(path.join(tmp, "sdk-snapshot.json"), '{"partial"', "utf8");
821
- const direct = await diffAndStoreValue("sdk-snapshot", { next: true }, ctx);
822
- assert.equal(direct.before, null);
823
- assert.equal(direct.changed, true);
824
- await fsp.writeFile(path.join(tmp, "stage-snapshot.json"), '{"partial"', "utf8");
825
- const stage = diffLast("stage-snapshot");
826
- const result = await stage.run({ input: streamOf([{ next: true }]), ctx });
827
- const output = [];
828
- for await (const item of result.output)
829
- output.push(item);
830
- assert.deepEqual(output, [
831
- {
832
- kind: "diff.last",
833
- key: "stage-snapshot",
834
- changed: true,
835
- before: null,
836
- after: { next: true },
837
- },
838
- ]);
839
- });
840
- test("SDK stateSet/readState is atomic under concurrent reads (#109)", async () => {
841
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-sdk-"));
842
- const ctx = { env: { LOBSTER_STATE_DIR: tmp } };
843
- const key = "sdk-state";
844
- const payload = "y".repeat(256 * 1024);
845
- const writeOnce = async (n) => {
846
- const prim = stateSet(key);
847
- const input = (async function* () {
848
- yield { payload, n };
849
- })();
850
- const res = await prim.run({ input, ctx });
851
- for await (const _ of res.output) {
852
- void _;
853
- }
854
- };
855
- await writeOnce(0);
856
- let readErrors = 0;
857
- let partialReads = 0;
858
- const reader = (async () => {
859
- for (let i = 0; i < 500; i++) {
860
- try {
861
- const v = await readState(key, ctx);
862
- if (!v || v.payload !== payload)
863
- partialReads++;
864
- }
865
- catch {
866
- readErrors++;
867
- }
868
- }
869
- })();
870
- const writer = (async () => {
871
- for (let n = 1; n <= 120; n++) {
872
- await writeOnce(n);
873
- }
874
- })();
875
- await Promise.all([reader, writer]);
876
- assert.equal(readErrors, 0, "SDK reader must never hit a parse/IO error mid-write");
877
- assert.equal(partialReads, 0, "SDK reader must never observe truncated/empty state");
878
- });
879
- test("SDK writeState preserves restricted state-file mode", async () => {
880
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-sdk-mode-"));
881
- const ctx = { env: { LOBSTER_STATE_DIR: tmp } };
882
- const filePath = path.join(tmp, "sdk-state.json");
883
- await fsp.mkdir(tmp, { recursive: true });
884
- await fsp.writeFile(filePath, '{"old":true}\n', { mode: 0o600 });
885
- await fsp.chmod(filePath, 0o600);
886
- await writeState("sdk-state", { ok: true }, ctx);
887
- assert.equal((await fsp.stat(filePath)).mode & 0o777, 0o600);
888
- assert.deepEqual(await readState("sdk-state", ctx), { ok: true });
889
- });
890
- test("SDK writeState removes temp files when replacement fails", async () => {
891
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-sdk-cleanup-"));
892
- const ctx = { env: { LOBSTER_STATE_DIR: tmp } };
893
- await fsp.mkdir(path.join(tmp, "sdk-state.json"));
894
- await assert.rejects(() => writeState("sdk-state", { ok: true }, ctx));
895
- const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
896
- assert.deepEqual(leftovers, []);
897
- });
898
- test("ensureDirectory creates missing parent directories", async () => {
899
- const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-ensure-dir-"));
900
- const nested = path.join(tmp, "alpha", "beta", "gamma");
901
- await ensureDirectory(nested);
902
- assert.equal((await fsp.stat(nested)).isDirectory(), true);
903
- // Re-running must stay a no-op once the whole chain already exists.
904
- await ensureDirectory(nested);
905
- assert.equal((await fsp.stat(nested)).isDirectory(), true);
906
- });
907
- test("stripExtendedLengthPrefix maps only namespaces with a plain equivalent", () => {
908
- assert.equal(stripExtendedLengthPrefix("\\\\?\\C:\\lobster\\state"), "C:\\lobster\\state");
909
- assert.equal(stripExtendedLengthPrefix("\\\\?\\UNC\\server\\share\\state"), "\\\\server\\share\\state");
910
- // Windows matches the namespace component case-insensitively, so a lowercase
911
- // marker names the same share and must map to the same plain path.
912
- assert.equal(stripExtendedLengthPrefix("\\\\?\\unc\\server\\share\\state"), "\\\\server\\share\\state");
913
- // A device namespace has no drive-letter form, so stripping it would leave a
914
- // relative path and break an explicitly configured state directory.
915
- const volume = "\\\\?\\Volume{6f4c2b1a-0000-0000-0000-000000000000}\\lobster\\state";
916
- assert.equal(stripExtendedLengthPrefix(volume), volume);
917
- assert.equal(stripExtendedLengthPrefix("/tmp/lobster/state"), "/tmp/lobster/state");
918
- });
919
- //# sourceMappingURL=state.test.js.map