@sanqianx/claude-ai-workbench 0.13.2 → 0.13.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.
Files changed (122) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +652 -613
  4. package/apps/agent-terminal/app.js +143 -67
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +172 -170
  7. package/apps/agent-terminal/panel-host-proof.html +152 -152
  8. package/apps/agent-terminal/panel-proof-content.html +41 -41
  9. package/apps/agent-terminal/right-workspace-dock.css +189 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +652 -521
  11. package/apps/agent-terminal/user-question-card.js +68 -0
  12. package/apps/ai-profile-settings-host/index.html +25 -25
  13. package/apps/ai-profile-settings-host/page.css +1 -1
  14. package/apps/ai-profile-settings-host/page.js +113 -113
  15. package/apps/minimal-test-host/README.md +15 -15
  16. package/apps/minimal-test-host/host.css +2 -2
  17. package/apps/minimal-test-host/host.js +35 -35
  18. package/apps/minimal-test-host/index.html +58 -58
  19. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  20. package/docs/sdk/FILE_LAYOUT.md +31 -31
  21. package/docs/sdk/INTEGRATION.md +126 -126
  22. package/docs/sdk/README.md +21 -21
  23. package/docs/sdk/SESSION_EVENTS.md +15 -13
  24. package/docs/sdk/openapi.yaml +16 -1
  25. package/package.json +1 -1
  26. package/packages/client/README.md +29 -29
  27. package/packages/client/index.d.ts +122 -122
  28. package/packages/client/index.js +260 -260
  29. package/packages/client/package.json +2 -2
  30. package/packages/contracts/README.md +9 -9
  31. package/packages/contracts/index.d.ts +519 -517
  32. package/packages/contracts/index.js +312 -312
  33. package/packages/contracts/package.json +1 -1
  34. package/packages/core/README.md +8 -8
  35. package/packages/core/index.d.ts +76 -76
  36. package/packages/core/index.js +222 -222
  37. package/packages/core/package.json +2 -2
  38. package/packages/server/README.md +17 -17
  39. package/packages/server/bin/agent-terminal-server.js +42 -42
  40. package/packages/server/bin/claude-workbench.js +225 -225
  41. package/packages/server/index.d.ts +171 -171
  42. package/packages/server/index.js +230 -230
  43. package/packages/server/lib/agent-discovery.js +133 -133
  44. package/packages/server/lib/agent-registry.js +143 -143
  45. package/packages/server/lib/agent-spawn-env.js +17 -17
  46. package/packages/server/lib/attachment-store.js +78 -78
  47. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  48. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  49. package/packages/server/lib/capture-settings-store.js +35 -35
  50. package/packages/server/lib/claude-history-recovery.js +87 -87
  51. package/packages/server/lib/cli-session-recovery.js +28 -28
  52. package/packages/server/lib/context-resolver.js +55 -55
  53. package/packages/server/lib/conversation-capture.js +51 -51
  54. package/packages/server/lib/delegation-phase-store.js +357 -357
  55. package/packages/server/lib/delegation-store.js +474 -468
  56. package/packages/server/lib/diagnostics.js +32 -32
  57. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  58. package/packages/server/lib/drivers/codex-driver.js +1461 -1442
  59. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  60. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  61. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  62. package/packages/server/lib/event-stream.js +138 -138
  63. package/packages/server/lib/folder-picker.js +623 -623
  64. package/packages/server/lib/http-handler.js +339 -339
  65. package/packages/server/lib/json-file.js +26 -26
  66. package/packages/server/lib/knowledge-context.js +33 -33
  67. package/packages/server/lib/model-detection.js +295 -295
  68. package/packages/server/lib/parent-context.js +44 -0
  69. package/packages/server/lib/profile-store.js +283 -283
  70. package/packages/server/lib/project-store.js +102 -102
  71. package/packages/server/lib/runtime-loader.js +120 -120
  72. package/packages/server/lib/session-archive-store.js +53 -53
  73. package/packages/server/lib/session-history.js +612 -612
  74. package/packages/server/lib/system-doctor.js +125 -125
  75. package/packages/server/lib/ui-preferences-store.js +48 -48
  76. package/packages/server/lib/user-questions.js +67 -0
  77. package/packages/server/lib/workbench-service.js +1696 -1675
  78. package/packages/server/package.json +2 -2
  79. package/packages/ui/README.md +30 -30
  80. package/packages/ui/ai-profile-settings.d.ts +45 -45
  81. package/packages/ui/ai-profile-settings.js +283 -283
  82. package/packages/ui/claude-terminal.d.ts +31 -31
  83. package/packages/ui/claude-terminal.js +343 -343
  84. package/packages/ui/index.d.ts +18 -18
  85. package/packages/ui/index.js +51 -51
  86. package/packages/ui/package.json +2 -2
  87. package/packages/ui/theme.css +13 -13
  88. package/src/assets/claude-prompts.json +21 -21
  89. package/src/backend/http/ai-profile-routes.js +61 -61
  90. package/src/backend/http/claude-routes.js +170 -170
  91. package/src/backend/http/node-helpers.js +45 -45
  92. package/src/backend/index.js +21 -21
  93. package/src/backend/lib/ai-adapter.js +144 -144
  94. package/src/backend/lib/ai-profile-store.js +259 -259
  95. package/src/backend/lib/ai-vendor-presets.js +83 -83
  96. package/src/backend/lib/ai-workspace.js +140 -140
  97. package/src/backend/lib/automation-config.js +253 -253
  98. package/src/backend/lib/claude-cli-runner.js +2408 -2392
  99. package/src/backend/lib/data-dir.js +169 -169
  100. package/src/backend/lib/llm-client.js +176 -176
  101. package/src/backend/lib/model-context-windows.js +82 -82
  102. package/src/backend/lib/native-task-control.js +217 -217
  103. package/src/backend/lib/native-task-history-index.js +475 -475
  104. package/src/backend/lib/native-task-history-reader.js +526 -526
  105. package/src/backend/lib/native-task-messages.js +457 -457
  106. package/src/backend/lib/native-task-registry.js +325 -325
  107. package/src/backend/lib/native-task-runtime.js +242 -242
  108. package/src/backend/lib/native-task-trace.js +440 -440
  109. package/src/backend/lib/native-task-transcript.js +393 -393
  110. package/src/backend/lib/prompt-registry.js +166 -166
  111. package/src/backend/lib/runtime-config.js +121 -121
  112. package/src/backend/lib/sdk-session-stream.js +143 -143
  113. package/src/backend/lib/sdk-turn-stream.js +16 -16
  114. package/src/backend/lib/store-scope.js +131 -131
  115. package/src/backend/lib/task-identity.js +104 -104
  116. package/src/backend/lib/task-state-projection.js +58 -58
  117. package/src/index.js +7 -7
  118. package/src/sdk-wave-a.ts +1 -1
  119. package/src/sdk-wave-b.ts +1 -1
  120. package/src/sdk-wave-c.ts +1 -1
  121. package/src/sdk-wave-d.ts +1 -1
  122. package/src/sdk-wave-e.ts +1 -1
@@ -1,468 +1,474 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const crypto = require('crypto');
6
-
7
- // Immutable reservation ledger for trusted Workbench delegation commands.
8
- //
9
- // Phase 1 (W1-28): records only RESERVE identity — delegationId plus a
10
- // reserved childSessionId — before any child session exists or any model
11
- // input is sent. There is deliberately no update, dispatch or delete path:
12
- // a reservation record is written once via exclusive create and never
13
- // rewritten. The ledger stores the canonical command binding (full TaskScope,
14
- // requestId, normalized payloadHash, resolved workspace, profile/model
15
- // selection) but never prompt bodies, credentials, env or private error text.
16
- //
17
- // Identity split (W1-DELEGATION-DECISIONS / W1-OWNER-DECISION): delegationId
18
- // identifies the Workbench server command. It is NOT an SDK run id; the record
19
- // carries sdkRunIdClaimed=false and inputCausalityClaimed=false because the
20
- // SDK has not been asked anything at reservation time. SDK ownerRunId keeps
21
- // its own meaning (sdk-launch facts only) and never appears here.
22
- //
23
- // Durability & concurrency: a record becomes visible only through
24
- // tmp-file + fsync + fs.link (create-if-absent). link() fails with EEXIST
25
- // when the record already exists, so two processes can never overwrite each
26
- // other's reservation; the loser re-reads and returns the original IDs. The
27
- // unique record file per (scope, requestId) is the single source of truth —
28
- // no secondary on-disk index exists to diverge, and getById rebuilds its
29
- // lookup by scanning records. Directory-entry fsync is attempted after the
30
- // link; on platforms whose primitives refuse it (Windows: EPERM) the store
31
- // records that capability instead of pretending durability it cannot get.
32
- // That directory fsync is a commit fence on EVERY reserve() success — fresh
33
- // commit, replay of an existing record, or an EEXIST loss to an identical
34
- // concurrent winner. A record whose fence never succeeded can never turn
35
- // into a success later without the fence being re-attempted in that same
36
- // call; the record itself is never rewritten, so recovery replays the
37
- // original IDs.
38
-
39
- const SCHEMA = 'workbench-delegation/v1';
40
- const VERSION = 1;
41
- const SOURCE = 'workbench-delegation-command';
42
- const BASIS = 'server-command';
43
- const PHASE = 'reserved';
44
- const OUTCOME = 'not-started';
45
- const RECORDS_DIR = 'records';
46
- const TMP_DIR = 'tmp';
47
- const NAME_RE = /^[0-9a-f]{64}\.json$/;
48
- const HASH_RE = /^[0-9a-f]{64}$/;
49
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
50
- const SESSION_ID_RE = /^sess-[0-9a-f]{12}$/;
51
- // The set of errnos that mean "this platform does not offer directory fsync
52
- // through this API" rather than "the committed record is in doubt".
53
- const DIR_FSYNC_UNSUPPORTED = new Set(['EPERM', 'EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EACCES']);
54
-
55
- function fail(code, status, message, extra) {
56
- const error = new Error(message);
57
- error.code = code;
58
- error.status = status;
59
- if (extra) Object.assign(error, extra);
60
- throw error;
61
- }
62
-
63
- function hasControlCharacter(value) {
64
- for (let k = 0; k < value.length; k += 1) {
65
- const code = value.charCodeAt(k);
66
- if (code < 0x20 || code === 0x7f) return true;
67
- }
68
- return false;
69
- }
70
-
71
- function isCleanString(value, max) {
72
- return typeof value === 'string' && value.length > 0 && value.length <= max
73
- && !hasControlCharacter(value);
74
- }
75
-
76
- function isPlainObject(value) {
77
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
78
- }
79
-
80
- function exactKeys(object, keys) {
81
- const actual = Object.keys(object);
82
- return actual.length === keys.length && keys.every(key => Object.prototype.hasOwnProperty.call(object, key));
83
- }
84
-
85
- function validateScope(scope) {
86
- if (!isPlainObject(scope) || !exactKeys(scope, ['storeScope', 'contextId', 'parentSessionId'])) {
87
- fail('DELEGATION_SCOPE_INVALID', 400, 'scope must be an object with exactly storeScope, contextId and parentSessionId');
88
- }
89
- if (!UUID_RE.test(scope.storeScope)) fail('DELEGATION_SCOPE_INVALID', 400, 'scope.storeScope must be a canonical lowercase UUID v4');
90
- if (!isCleanString(scope.contextId, 128)) fail('DELEGATION_SCOPE_INVALID', 400, 'scope.contextId must be a non-empty string');
91
- if (!isCleanString(scope.parentSessionId, 128)) fail('DELEGATION_SCOPE_INVALID', 400, 'scope.parentSessionId must be a non-empty string');
92
- return { storeScope: scope.storeScope, contextId: scope.contextId, parentSessionId: scope.parentSessionId };
93
- }
94
-
95
- function validateWorkspace(workspace) {
96
- if (!isPlainObject(workspace) || !exactKeys(workspace, ['ref', 'rootPath', 'resolvedFrom'])) {
97
- fail('DELEGATION_WORKSPACE_INVALID', 400, 'workspace must be an object with exactly ref, rootPath and resolvedFrom');
98
- }
99
- const { ref, rootPath, resolvedFrom } = workspace;
100
- if (!isCleanString(ref, 128) || path.isAbsolute(ref) || /[\\/]/.test(ref)) {
101
- fail('DELEGATION_WORKSPACE_INVALID', 400, 'workspace.ref must be an opaque registered reference, not a path');
102
- }
103
- if (typeof rootPath !== 'string' || !path.isAbsolute(rootPath) || !isCleanString(rootPath.replace(/\s/g, ''), 1024)) {
104
- fail('DELEGATION_WORKSPACE_INVALID', 400, 'workspace.rootPath must be an absolute local directory path');
105
- }
106
- if (resolvedFrom !== 'parent-session-record') {
107
- fail('DELEGATION_WORKSPACE_INVALID', 400, "workspace.resolvedFrom must be 'parent-session-record'");
108
- }
109
- return { ref, rootPath, resolvedFrom };
110
- }
111
-
112
- function validateSelection(selection) {
113
- if (!isPlainObject(selection) || !exactKeys(selection, ['profileId', 'model'])) {
114
- fail('DELEGATION_SELECTION_REJECTED', 400, 'selection must be an object with exactly profileId and model; credentials and env are never accepted or stored');
115
- }
116
- for (const key of ['profileId', 'model']) {
117
- const value = selection[key];
118
- if (value !== null && !isCleanString(value, 128)) {
119
- fail('DELEGATION_SELECTION_REJECTED', 400, `selection.${key} must be null or a non-empty string`);
120
- }
121
- }
122
- return { profileId: selection.profileId, model: selection.model };
123
- }
124
-
125
- function requestHashOf(scope, requestId) {
126
- const canonical = JSON.stringify([scope.storeScope, scope.contextId, scope.parentSessionId, requestId]);
127
- return crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
128
- }
129
-
130
- function newDelegationId() { return crypto.randomUUID(); }
131
-
132
- function newChildSessionId() { return `sess-${crypto.randomBytes(6).toString('hex')}`; }
133
-
134
- function canonicalBinding(scope, requestId, payloadHash, workspace, selection) {
135
- return {
136
- scope: { storeScope: scope.storeScope, contextId: scope.contextId, parentSessionId: scope.parentSessionId },
137
- requestId,
138
- payloadHash,
139
- workspace: { ref: workspace.ref, rootPath: workspace.rootPath, resolvedFrom: workspace.resolvedFrom },
140
- selection: { profileId: selection.profileId, model: selection.model },
141
- };
142
- }
143
-
144
- // Full self-consistency validation of a stored record. Returns a normalized
145
- // deep copy or throws CORRUPT — the file itself is never touched.
146
- function validateRecord(parsed, entryName) {
147
- const corrupt = detail => fail('DELEGATION_RECORD_CORRUPT', 500, `stored delegation record is invalid: ${detail}`, { entry: entryName });
148
- if (!isPlainObject(parsed)) corrupt('not an object');
149
- if (parsed.schema !== SCHEMA || parsed.version !== VERSION) corrupt('unknown schema/version');
150
- if (!UUID_RE.test(parsed.delegationId || '')) corrupt('delegationId is not a UUID');
151
- if (!SESSION_ID_RE.test(parsed.childSessionId || '')) corrupt('childSessionId is not a reserved session id');
152
- let scope;
153
- try { scope = validateScope(parsed.scope); } catch { corrupt('scope invalid'); }
154
- if (!isCleanString(parsed.requestId || '', 256)) corrupt('requestId invalid');
155
- if (!HASH_RE.test(parsed.requestHash || '')) corrupt('requestHash invalid');
156
- if (!HASH_RE.test(parsed.payloadHash || '')) corrupt('payloadHash invalid');
157
- let workspace;
158
- let selection;
159
- try { workspace = validateWorkspace(parsed.workspace); } catch { corrupt('workspace invalid'); }
160
- try { selection = validateSelection(parsed.selection); } catch { corrupt('selection invalid'); }
161
- if (requestHashOf(scope, parsed.requestId) !== parsed.requestHash) corrupt('requestHash does not match scope+requestId');
162
- if (typeof parsed.issuedAt !== 'string' || !Number.isFinite(Date.parse(parsed.issuedAt))) corrupt('issuedAt invalid');
163
- if (parsed.source !== SOURCE || parsed.basis !== BASIS || parsed.phase !== PHASE || parsed.outcome !== OUTCOME) {
164
- corrupt('source/basis/phase/outcome literals changed');
165
- }
166
- if (parsed.sdkRunIdClaimed !== false || parsed.inputCausalityClaimed !== false) {
167
- corrupt('sdk claims must stay false at reservation time');
168
- }
169
- return {
170
- schema: SCHEMA, version: VERSION,
171
- delegationId: parsed.delegationId, childSessionId: parsed.childSessionId,
172
- scope, requestId: parsed.requestId, requestHash: parsed.requestHash, payloadHash: parsed.payloadHash,
173
- workspace, selection, issuedAt: parsed.issuedAt,
174
- source: SOURCE, basis: BASIS, phase: PHASE, outcome: OUTCOME,
175
- sdkRunIdClaimed: false, inputCausalityClaimed: false,
176
- };
177
- }
178
-
179
- function sameBinding(record, binding) {
180
- return JSON.stringify(canonicalBinding(record.scope, record.requestId, record.payloadHash, record.workspace, record.selection))
181
- === JSON.stringify(binding);
182
- }
183
-
184
- function bindingDifference(record, binding) {
185
- const fields = ['payloadHash'];
186
- if (record.payloadHash !== binding.payloadHash) return 'payloadHash';
187
- if (JSON.stringify(record.workspace) !== JSON.stringify(binding.workspace)) return 'workspace';
188
- if (JSON.stringify(record.selection) !== JSON.stringify(binding.selection)) return 'selection';
189
- return fields[0];
190
- }
191
-
192
- function defaultDirFsync(f, dirPath) {
193
- let fd;
194
- try {
195
- fd = f.openSync(dirPath, 'r');
196
- f.fsyncSync(fd);
197
- return 'supported';
198
- } catch (error) {
199
- if (DIR_FSYNC_UNSUPPORTED.has(error && error.code)) return 'unsupported';
200
- throw error;
201
- } finally {
202
- if (fd !== undefined) { try { f.closeSync(fd); } catch { /* closed or unusable */ } }
203
- }
204
- }
205
-
206
- function createDelegationStore(options = {}) {
207
- const { root, storeScope } = options || {};
208
- // fsx is an internal seam for fault-injection tests; production callers
209
- // omit it and get the real fs module.
210
- const f = options.fsx || fs;
211
- const dirFsync = typeof f.dirFsync === 'function' ? f.dirFsync.bind(f) : dirPath => defaultDirFsync(f, dirPath);
212
- if (typeof root !== 'string' || !root.trim() || !path.isAbsolute(root)) {
213
- fail('DELEGATION_STORE_ROOT_INVALID', 400, 'root must be an absolute server-owned directory');
214
- }
215
- if (!UUID_RE.test(storeScope || '')) {
216
- fail('DELEGATION_STORE_SCOPE_INVALID', 400, 'storeScope must be a canonical lowercase UUID v4');
217
- }
218
- const rootDir = path.resolve(root);
219
- const recordsDir = path.join(rootDir, RECORDS_DIR);
220
- const tmpDir = path.join(rootDir, TMP_DIR);
221
-
222
- function ensureRealDir(dirPath, what) {
223
- try {
224
- f.mkdirSync(dirPath, { recursive: true });
225
- } catch (error) {
226
- // recursive mkdir reports EEXIST/ENOTDIR when a non-directory already
227
- // occupies the path; the lstat classification below gives the honest
228
- // answer, and anything else (EACCES, EROFS, ...) is a real failure.
229
- if (!(error && (error.code === 'EEXIST' || error.code === 'ENOTDIR'))) throw error;
230
- }
231
- const st = f.lstatSync(dirPath);
232
- if (st.isSymbolicLink()) fail('DELEGATION_STORE_PATH_UNSAFE', 400, `${what} must not be a symbolic link`, { path: what });
233
- if (!st.isDirectory()) fail('DELEGATION_STORE_PATH_UNSAFE', 400, `${what} must be a real directory`, { path: what });
234
- }
235
-
236
- ensureRealDir(rootDir, 'store root');
237
- ensureRealDir(recordsDir, 'records directory');
238
- ensureRealDir(tmpDir, 'tmp directory');
239
-
240
- let directoryFsync = 'unknown';
241
- let tmpCounter = 0;
242
-
243
- const recordPathOf = requestHash => path.join(recordsDir, `${requestHash}.json`);
244
-
245
- // Reads a record file and validates it against the caller's expectation.
246
- // 'missing' | { record } — corrupt/unsafe files throw and are preserved.
247
- function readRecord(requestHash, expected) {
248
- const file = recordPathOf(requestHash);
249
- let st;
250
- try { st = f.lstatSync(file); } catch (error) {
251
- if (error && error.code === 'ENOENT') return { status: 'missing' };
252
- throw error;
253
- }
254
- const entry = path.basename(file);
255
- if (st.isSymbolicLink()) fail('DELEGATION_RECORD_INVALID', 500, 'record entry must not be a symbolic link', { entry });
256
- if (!st.isFile()) fail('DELEGATION_RECORD_INVALID', 500, 'record entry must be a regular file', { entry });
257
- // A transient read error (AV lock, EBUSY, EIO) must surface as itself:
258
- // folding it into "corrupt" would permanently block a healthy request.
259
- let parsed;
260
- try { parsed = JSON.parse(f.readFileSync(file, 'utf8')); } catch (error) {
261
- if (error instanceof SyntaxError) parsed = null;
262
- else throw error;
263
- }
264
- const record = validateRecord(parsed, entry);
265
- if (record.requestHash !== requestHash) fail('DELEGATION_RECORD_CORRUPT', 500, 'record does not belong to its request path', { entry });
266
- if (expected) {
267
- if (JSON.stringify(record.scope) !== JSON.stringify(expected.scope)) {
268
- fail('DELEGATION_RECORD_CORRUPT', 500, 'record scope does not match its request path', { entry });
269
- }
270
- if (record.requestId !== expected.requestId) {
271
- fail('DELEGATION_RECORD_CORRUPT', 500, 'record requestId does not match its request path', { entry });
272
- }
273
- }
274
- return { status: 'ok', record };
275
- }
276
-
277
- // Every entry in the records directory must be a well-named regular file;
278
- // anything else is tampering and fails loudly — never a silent empty list.
279
- function scanRecordNames() {
280
- const names = f.readdirSync(recordsDir).sort();
281
- for (const name of names) {
282
- if (!NAME_RE.test(name)) {
283
- fail('DELEGATION_STORE_DIR_INVALID', 500, 'records directory contains an unexpected entry', { entry: name });
284
- }
285
- }
286
- return names;
287
- }
288
-
289
- function cleanupOwnTmp(tmpPath) {
290
- const resolved = path.resolve(tmpPath);
291
- if (resolved !== tmpPath || !resolved.startsWith(tmpDir + path.sep) || path.basename(resolved).includes(path.sep)) {
292
- return; // not provably this call's own temp file inside the dedicated root: leave it alone
293
- }
294
- try { f.unlinkSync(resolved); } catch (error) { if (error && error.code !== 'ENOENT') throw error; }
295
- }
296
-
297
- function returnCopy(record) {
298
- return JSON.parse(JSON.stringify(record));
299
- }
300
-
301
- function requireBoundScope(scope) {
302
- const valid = validateScope(scope);
303
- if (valid.storeScope !== storeScope) {
304
- fail('DELEGATION_STORE_SCOPE_MISMATCH', 400, 'this ledger is bound to a different storeScope');
305
- }
306
- return valid;
307
- }
308
-
309
- // Every success a caller could act on (a later dispatch phase starts from
310
- // these IDs) must pass the commit fence within the same reserve() call:
311
- // fresh commits, replays of existing records, and EEXIST losses to an
312
- // identical winner are all fenced. The fence either syncs the directory
313
- // entry, discloses that the platform primitive is unavailable, or fails —
314
- // a record whose fence never succeeded must not be returned as success
315
- // later without the fence being re-attempted. The record is never
316
- // rewritten by this path, so a retry after recovery replays the same IDs.
317
- function commitFence(after) {
318
- try {
319
- directoryFsync = dirFsync(recordsDir);
320
- } catch (error) {
321
- fail('DELEGATION_DIR_FSYNC_FAILED', 500, `reservation ${after}: commit fence (directory fsync) failed; the record stays untouched and a retry after recovery returns the same ids`, { reason: error && error.code });
322
- }
323
- }
324
-
325
- function reserve(input = {}) {
326
- const scope = requireBoundScope(validateScope(input.scope));
327
- if (!isCleanString(input.requestId, 256)) fail('DELEGATION_REQUEST_INVALID', 400, 'requestId must be a non-empty string without control characters');
328
- const requestId = input.requestId;
329
- if (typeof input.payloadHash !== 'string' || !HASH_RE.test(input.payloadHash.toLowerCase())) {
330
- fail('DELEGATION_PAYLOAD_HASH_INVALID', 400, 'payloadHash must be the server-computed canonical command sha256 hex');
331
- }
332
- const payloadHash = input.payloadHash.toLowerCase();
333
- const workspace = validateWorkspace(input.workspace);
334
- const selection = validateSelection(input.selection);
335
- const binding = canonicalBinding(scope, requestId, payloadHash, workspace, selection);
336
- const requestHash = requestHashOf(scope, requestId);
337
-
338
- const existing = readRecord(requestHash, { scope, requestId });
339
- if (existing.status === 'ok') {
340
- if (sameBinding(existing.record, binding)) {
341
- commitFence('replay');
342
- return returnCopy(existing.record);
343
- }
344
- fail('DELEGATION_PAYLOAD_CONFLICT', 409, `a reservation already exists for this requestId with a different ${bindingDifference(existing.record, binding)}`);
345
- }
346
-
347
- const record = {
348
- schema: SCHEMA, version: VERSION,
349
- delegationId: newDelegationId(), childSessionId: newChildSessionId(),
350
- scope, requestId, requestHash, payloadHash,
351
- workspace, selection,
352
- issuedAt: new Date().toISOString(),
353
- source: SOURCE, basis: BASIS, phase: PHASE, outcome: OUTCOME,
354
- sdkRunIdClaimed: false, inputCausalityClaimed: false,
355
- };
356
- const payload = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
357
- tmpCounter += 1;
358
- const tmpPath = path.join(tmpDir, `${process.pid}-${tmpCounter}-${crypto.randomUUID()}.tmp`);
359
- let fd;
360
- try {
361
- fd = f.openSync(tmpPath, 'wx');
362
- let written = 0;
363
- while (written < payload.length) {
364
- // writeSync returns THIS call's byte count: accumulate it and keep
365
- // passing the true offset/length, so a legal short write never
366
- // re-writes or skips bytes. A zero return means no progress at all
367
- // and would otherwise spin forever — fail on the first one.
368
- const bytesWritten = f.writeSync(fd, payload, written, payload.length - written);
369
- if (!(bytesWritten > 0)) {
370
- const noProgress = new Error('writeSync returned no progress');
371
- noProgress.code = 'write-zero-progress';
372
- throw noProgress;
373
- }
374
- written += bytesWritten;
375
- }
376
- f.fsyncSync(fd);
377
- } catch (error) {
378
- if (fd !== undefined) { try { f.closeSync(fd); } catch { /* already closed */ } }
379
- try { cleanupOwnTmp(tmpPath); } catch { /* failure below is the honest answer */ }
380
- fail('DELEGATION_WRITE_FAILED', 500, 'reservation record could not be written durably; nothing was reserved', { reason: error && error.code });
381
- }
382
- try { f.closeSync(fd); } catch { /* data already fsynced; close failure is not a durability loss */ }
383
- try {
384
- f.linkSync(tmpPath, recordPathOf(requestHash));
385
- } catch (error) {
386
- try { cleanupOwnTmp(tmpPath); } catch { /* reported below */ }
387
- if (error && error.code === 'EEXIST') {
388
- const winner = readRecord(requestHash, { scope, requestId });
389
- if (winner.status !== 'ok') {
390
- fail('DELEGATION_RECORD_CORRUPT', 500, 'concurrent reservation record exists but cannot be validated', { entry: `${requestHash}.json` });
391
- }
392
- if (sameBinding(winner.record, binding)) {
393
- commitFence('lost-race');
394
- return returnCopy(winner.record);
395
- }
396
- fail('DELEGATION_PAYLOAD_CONFLICT', 409, `a concurrent reservation won this requestId with a different ${bindingDifference(winner.record, binding)}`);
397
- }
398
- fail('DELEGATION_COMMIT_FAILED', 500, 'atomic reservation commit is unavailable on this platform/path; nothing was reserved', { reason: error && error.code });
399
- }
400
- // The link is visible and the content was fsynced before it. The commit
401
- // fence syncs the directory entry (or discloses the capability gap via
402
- // describe()); an unexpected failure surfaces as an error while the
403
- // record itself stays intact — a retry re-attempts the fence and then
404
- // replays the same IDs.
405
- try {
406
- commitFence('commit');
407
- } catch (error) {
408
- try { cleanupOwnTmp(tmpPath); } catch { /* the fence failure is the honest answer */ }
409
- throw error;
410
- }
411
- try { cleanupOwnTmp(tmpPath); } catch (error) {
412
- fail('DELEGATION_TMP_CLEANUP_FAILED', 500, 'reservation committed but its temp file could not be removed', { reason: error && error.code });
413
- }
414
- return returnCopy(record);
415
- }
416
-
417
- function getByRequest(scope, requestId) {
418
- const valid = requireBoundScope(scope);
419
- if (!isCleanString(requestId, 256)) fail('DELEGATION_REQUEST_INVALID', 400, 'requestId must be a non-empty string without control characters');
420
- const found = readRecord(requestHashOf(valid, requestId), { scope: valid, requestId });
421
- return found.status === 'ok' ? returnCopy(found.record) : null;
422
- }
423
-
424
- function getById(scope, delegationId) {
425
- const valid = requireBoundScope(scope);
426
- if (typeof delegationId !== 'string' || !UUID_RE.test(delegationId)) {
427
- fail('DELEGATION_ID_INVALID', 400, 'delegationId must be a UUID issued by this ledger');
428
- }
429
- for (const name of scanRecordNames()) {
430
- const requestHash = name.slice(0, -'.json'.length);
431
- const found = readRecord(requestHash, null);
432
- if (found.record.delegationId === delegationId) {
433
- if (JSON.stringify(found.record.scope) !== JSON.stringify(valid)) {
434
- fail('DELEGATION_SCOPE_MISMATCH', 403, 'this delegationId belongs to a different scope', { delegationId });
435
- }
436
- return returnCopy(found.record);
437
- }
438
- }
439
- return null;
440
- }
441
-
442
- function list(scope) {
443
- const valid = requireBoundScope(scope);
444
- const records = [];
445
- for (const name of scanRecordNames()) {
446
- const found = readRecord(name.slice(0, -'.json'.length), null);
447
- if (JSON.stringify(found.record.scope) === JSON.stringify(valid)) records.push(found.record);
448
- }
449
- records.sort((a, b) => a.issuedAt.localeCompare(b.issuedAt) || a.delegationId.localeCompare(b.delegationId));
450
- return records.map(returnCopy);
451
- }
452
-
453
- return {
454
- reserve,
455
- getByRequest,
456
- getById,
457
- list,
458
- describe() {
459
- return { schema: SCHEMA, version: VERSION, root: rootDir, storeScope, directoryFsync };
460
- },
461
- };
462
- }
463
-
464
- module.exports = {
465
- createDelegationStore,
466
- DELEGATION_SCHEMA: SCHEMA,
467
- DELEGATION_VERSION: VERSION,
468
- };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+
7
+ // Immutable reservation ledger for trusted Workbench delegation commands.
8
+ //
9
+ // Phase 1 (W1-28): records only RESERVE identity — delegationId plus a
10
+ // reserved childSessionId — before any child session exists or any model
11
+ // input is sent. There is deliberately no update, dispatch or delete path:
12
+ // a reservation record is written once via exclusive create and never
13
+ // rewritten. The ledger stores the canonical command binding (full TaskScope,
14
+ // requestId, normalized payloadHash, resolved workspace, profile/model
15
+ // selection). Opt-in parent snapshots are private context on that immutable
16
+ // reservation; credentials, env and private error text are never stored here.
17
+ //
18
+ // Identity split (W1-DELEGATION-DECISIONS / W1-OWNER-DECISION): delegationId
19
+ // identifies the Workbench server command. It is NOT an SDK run id; the record
20
+ // carries sdkRunIdClaimed=false and inputCausalityClaimed=false because the
21
+ // SDK has not been asked anything at reservation time. SDK ownerRunId keeps
22
+ // its own meaning (sdk-launch facts only) and never appears here.
23
+ //
24
+ // Durability & concurrency: a record becomes visible only through
25
+ // tmp-file + fsync + fs.link (create-if-absent). link() fails with EEXIST
26
+ // when the record already exists, so two processes can never overwrite each
27
+ // other's reservation; the loser re-reads and returns the original IDs. The
28
+ // unique record file per (scope, requestId) is the single source of truth —
29
+ // no secondary on-disk index exists to diverge, and getById rebuilds its
30
+ // lookup by scanning records. Directory-entry fsync is attempted after the
31
+ // link; on platforms whose primitives refuse it (Windows: EPERM) the store
32
+ // records that capability instead of pretending durability it cannot get.
33
+ // That directory fsync is a commit fence on EVERY reserve() success — fresh
34
+ // commit, replay of an existing record, or an EEXIST loss to an identical
35
+ // concurrent winner. A record whose fence never succeeded can never turn
36
+ // into a success later without the fence being re-attempted in that same
37
+ // call; the record itself is never rewritten, so recovery replays the
38
+ // original IDs.
39
+
40
+ const SCHEMA = 'workbench-delegation/v1';
41
+ const VERSION = 1;
42
+ const SOURCE = 'workbench-delegation-command';
43
+ const BASIS = 'server-command';
44
+ const PHASE = 'reserved';
45
+ const OUTCOME = 'not-started';
46
+ const RECORDS_DIR = 'records';
47
+ const TMP_DIR = 'tmp';
48
+ const NAME_RE = /^[0-9a-f]{64}\.json$/;
49
+ const HASH_RE = /^[0-9a-f]{64}$/;
50
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
51
+ const SESSION_ID_RE = /^sess-[0-9a-f]{12}$/;
52
+ // The set of errnos that mean "this platform does not offer directory fsync
53
+ // through this API" rather than "the committed record is in doubt".
54
+ const DIR_FSYNC_UNSUPPORTED = new Set(['EPERM', 'EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EACCES']);
55
+
56
+ function fail(code, status, message, extra) {
57
+ const error = new Error(message);
58
+ error.code = code;
59
+ error.status = status;
60
+ if (extra) Object.assign(error, extra);
61
+ throw error;
62
+ }
63
+
64
+ function hasControlCharacter(value) {
65
+ for (let k = 0; k < value.length; k += 1) {
66
+ const code = value.charCodeAt(k);
67
+ if (code < 0x20 || code === 0x7f) return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ function isCleanString(value, max) {
73
+ return typeof value === 'string' && value.length > 0 && value.length <= max
74
+ && !hasControlCharacter(value);
75
+ }
76
+
77
+ function isPlainObject(value) {
78
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
79
+ }
80
+
81
+ function exactKeys(object, keys) {
82
+ const actual = Object.keys(object);
83
+ return actual.length === keys.length && keys.every(key => Object.prototype.hasOwnProperty.call(object, key));
84
+ }
85
+
86
+ function validateScope(scope) {
87
+ if (!isPlainObject(scope) || !exactKeys(scope, ['storeScope', 'contextId', 'parentSessionId'])) {
88
+ fail('DELEGATION_SCOPE_INVALID', 400, 'scope must be an object with exactly storeScope, contextId and parentSessionId');
89
+ }
90
+ if (!UUID_RE.test(scope.storeScope)) fail('DELEGATION_SCOPE_INVALID', 400, 'scope.storeScope must be a canonical lowercase UUID v4');
91
+ if (!isCleanString(scope.contextId, 128)) fail('DELEGATION_SCOPE_INVALID', 400, 'scope.contextId must be a non-empty string');
92
+ if (!isCleanString(scope.parentSessionId, 128)) fail('DELEGATION_SCOPE_INVALID', 400, 'scope.parentSessionId must be a non-empty string');
93
+ return { storeScope: scope.storeScope, contextId: scope.contextId, parentSessionId: scope.parentSessionId };
94
+ }
95
+
96
+ function validateWorkspace(workspace) {
97
+ if (!isPlainObject(workspace) || !exactKeys(workspace, ['ref', 'rootPath', 'resolvedFrom'])) {
98
+ fail('DELEGATION_WORKSPACE_INVALID', 400, 'workspace must be an object with exactly ref, rootPath and resolvedFrom');
99
+ }
100
+ const { ref, rootPath, resolvedFrom } = workspace;
101
+ if (!isCleanString(ref, 128) || path.isAbsolute(ref) || /[\\/]/.test(ref)) {
102
+ fail('DELEGATION_WORKSPACE_INVALID', 400, 'workspace.ref must be an opaque registered reference, not a path');
103
+ }
104
+ if (typeof rootPath !== 'string' || !path.isAbsolute(rootPath) || !isCleanString(rootPath.replace(/\s/g, ''), 1024)) {
105
+ fail('DELEGATION_WORKSPACE_INVALID', 400, 'workspace.rootPath must be an absolute local directory path');
106
+ }
107
+ if (resolvedFrom !== 'parent-session-record') {
108
+ fail('DELEGATION_WORKSPACE_INVALID', 400, "workspace.resolvedFrom must be 'parent-session-record'");
109
+ }
110
+ return { ref, rootPath, resolvedFrom };
111
+ }
112
+
113
+ function validateSelection(selection) {
114
+ if (!isPlainObject(selection) || !exactKeys(selection, ['profileId', 'model'])) {
115
+ fail('DELEGATION_SELECTION_REJECTED', 400, 'selection must be an object with exactly profileId and model; credentials and env are never accepted or stored');
116
+ }
117
+ for (const key of ['profileId', 'model']) {
118
+ const value = selection[key];
119
+ if (value !== null && !isCleanString(value, 128)) {
120
+ fail('DELEGATION_SELECTION_REJECTED', 400, `selection.${key} must be null or a non-empty string`);
121
+ }
122
+ }
123
+ return { profileId: selection.profileId, model: selection.model };
124
+ }
125
+
126
+ function requestHashOf(scope, requestId) {
127
+ const canonical = JSON.stringify([scope.storeScope, scope.contextId, scope.parentSessionId, requestId]);
128
+ return crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
129
+ }
130
+
131
+ function newDelegationId() { return crypto.randomUUID(); }
132
+
133
+ function newChildSessionId() { return `sess-${crypto.randomBytes(6).toString('hex')}`; }
134
+
135
+ function canonicalBinding(scope, requestId, payloadHash, workspace, selection) {
136
+ return {
137
+ scope: { storeScope: scope.storeScope, contextId: scope.contextId, parentSessionId: scope.parentSessionId },
138
+ requestId,
139
+ payloadHash,
140
+ workspace: { ref: workspace.ref, rootPath: workspace.rootPath, resolvedFrom: workspace.resolvedFrom },
141
+ selection: { profileId: selection.profileId, model: selection.model },
142
+ };
143
+ }
144
+
145
+ // Full self-consistency validation of a stored record. Returns a normalized
146
+ // deep copy or throws CORRUPT — the file itself is never touched.
147
+ function validateRecord(parsed, entryName) {
148
+ const corrupt = detail => fail('DELEGATION_RECORD_CORRUPT', 500, `stored delegation record is invalid: ${detail}`, { entry: entryName });
149
+ if (!isPlainObject(parsed)) corrupt('not an object');
150
+ let context;
151
+ if (parsed.context !== undefined) {
152
+ try { context = require('./parent-context').validateParentContext(parsed.context); } catch { corrupt('context snapshot invalid'); }
153
+ }
154
+ if (parsed.schema !== SCHEMA || parsed.version !== VERSION) corrupt('unknown schema/version');
155
+ if (!UUID_RE.test(parsed.delegationId || '')) corrupt('delegationId is not a UUID');
156
+ if (!SESSION_ID_RE.test(parsed.childSessionId || '')) corrupt('childSessionId is not a reserved session id');
157
+ let scope;
158
+ try { scope = validateScope(parsed.scope); } catch { corrupt('scope invalid'); }
159
+ if (!isCleanString(parsed.requestId || '', 256)) corrupt('requestId invalid');
160
+ if (!HASH_RE.test(parsed.requestHash || '')) corrupt('requestHash invalid');
161
+ if (!HASH_RE.test(parsed.payloadHash || '')) corrupt('payloadHash invalid');
162
+ let workspace;
163
+ let selection;
164
+ try { workspace = validateWorkspace(parsed.workspace); } catch { corrupt('workspace invalid'); }
165
+ try { selection = validateSelection(parsed.selection); } catch { corrupt('selection invalid'); }
166
+ if (requestHashOf(scope, parsed.requestId) !== parsed.requestHash) corrupt('requestHash does not match scope+requestId');
167
+ if (typeof parsed.issuedAt !== 'string' || !Number.isFinite(Date.parse(parsed.issuedAt))) corrupt('issuedAt invalid');
168
+ if (parsed.source !== SOURCE || parsed.basis !== BASIS || parsed.phase !== PHASE || parsed.outcome !== OUTCOME) {
169
+ corrupt('source/basis/phase/outcome literals changed');
170
+ }
171
+ if (parsed.sdkRunIdClaimed !== false || parsed.inputCausalityClaimed !== false) {
172
+ corrupt('sdk claims must stay false at reservation time');
173
+ }
174
+ return {
175
+ schema: SCHEMA, version: VERSION,
176
+ delegationId: parsed.delegationId, childSessionId: parsed.childSessionId,
177
+ scope, requestId: parsed.requestId, requestHash: parsed.requestHash, payloadHash: parsed.payloadHash,
178
+ workspace, selection, issuedAt: parsed.issuedAt, ...(context ? { context } : {}),
179
+ source: SOURCE, basis: BASIS, phase: PHASE, outcome: OUTCOME,
180
+ sdkRunIdClaimed: false, inputCausalityClaimed: false,
181
+ };
182
+ }
183
+
184
+ function sameBinding(record, binding) {
185
+ return JSON.stringify(canonicalBinding(record.scope, record.requestId, record.payloadHash, record.workspace, record.selection))
186
+ === JSON.stringify(binding);
187
+ }
188
+
189
+ function bindingDifference(record, binding) {
190
+ const fields = ['payloadHash'];
191
+ if (record.payloadHash !== binding.payloadHash) return 'payloadHash';
192
+ if (JSON.stringify(record.workspace) !== JSON.stringify(binding.workspace)) return 'workspace';
193
+ if (JSON.stringify(record.selection) !== JSON.stringify(binding.selection)) return 'selection';
194
+ return fields[0];
195
+ }
196
+
197
+ function defaultDirFsync(f, dirPath) {
198
+ let fd;
199
+ try {
200
+ fd = f.openSync(dirPath, 'r');
201
+ f.fsyncSync(fd);
202
+ return 'supported';
203
+ } catch (error) {
204
+ if (DIR_FSYNC_UNSUPPORTED.has(error && error.code)) return 'unsupported';
205
+ throw error;
206
+ } finally {
207
+ if (fd !== undefined) { try { f.closeSync(fd); } catch { /* closed or unusable */ } }
208
+ }
209
+ }
210
+
211
+ function createDelegationStore(options = {}) {
212
+ const { root, storeScope } = options || {};
213
+ // fsx is an internal seam for fault-injection tests; production callers
214
+ // omit it and get the real fs module.
215
+ const f = options.fsx || fs;
216
+ const dirFsync = typeof f.dirFsync === 'function' ? f.dirFsync.bind(f) : dirPath => defaultDirFsync(f, dirPath);
217
+ if (typeof root !== 'string' || !root.trim() || !path.isAbsolute(root)) {
218
+ fail('DELEGATION_STORE_ROOT_INVALID', 400, 'root must be an absolute server-owned directory');
219
+ }
220
+ if (!UUID_RE.test(storeScope || '')) {
221
+ fail('DELEGATION_STORE_SCOPE_INVALID', 400, 'storeScope must be a canonical lowercase UUID v4');
222
+ }
223
+ const rootDir = path.resolve(root);
224
+ const recordsDir = path.join(rootDir, RECORDS_DIR);
225
+ const tmpDir = path.join(rootDir, TMP_DIR);
226
+
227
+ function ensureRealDir(dirPath, what) {
228
+ try {
229
+ f.mkdirSync(dirPath, { recursive: true });
230
+ } catch (error) {
231
+ // recursive mkdir reports EEXIST/ENOTDIR when a non-directory already
232
+ // occupies the path; the lstat classification below gives the honest
233
+ // answer, and anything else (EACCES, EROFS, ...) is a real failure.
234
+ if (!(error && (error.code === 'EEXIST' || error.code === 'ENOTDIR'))) throw error;
235
+ }
236
+ const st = f.lstatSync(dirPath);
237
+ if (st.isSymbolicLink()) fail('DELEGATION_STORE_PATH_UNSAFE', 400, `${what} must not be a symbolic link`, { path: what });
238
+ if (!st.isDirectory()) fail('DELEGATION_STORE_PATH_UNSAFE', 400, `${what} must be a real directory`, { path: what });
239
+ }
240
+
241
+ ensureRealDir(rootDir, 'store root');
242
+ ensureRealDir(recordsDir, 'records directory');
243
+ ensureRealDir(tmpDir, 'tmp directory');
244
+
245
+ let directoryFsync = 'unknown';
246
+ let tmpCounter = 0;
247
+
248
+ const recordPathOf = requestHash => path.join(recordsDir, `${requestHash}.json`);
249
+
250
+ // Reads a record file and validates it against the caller's expectation.
251
+ // 'missing' | { record } — corrupt/unsafe files throw and are preserved.
252
+ function readRecord(requestHash, expected) {
253
+ const file = recordPathOf(requestHash);
254
+ let st;
255
+ try { st = f.lstatSync(file); } catch (error) {
256
+ if (error && error.code === 'ENOENT') return { status: 'missing' };
257
+ throw error;
258
+ }
259
+ const entry = path.basename(file);
260
+ if (st.isSymbolicLink()) fail('DELEGATION_RECORD_INVALID', 500, 'record entry must not be a symbolic link', { entry });
261
+ if (!st.isFile()) fail('DELEGATION_RECORD_INVALID', 500, 'record entry must be a regular file', { entry });
262
+ // A transient read error (AV lock, EBUSY, EIO) must surface as itself:
263
+ // folding it into "corrupt" would permanently block a healthy request.
264
+ let parsed;
265
+ try { parsed = JSON.parse(f.readFileSync(file, 'utf8')); } catch (error) {
266
+ if (error instanceof SyntaxError) parsed = null;
267
+ else throw error;
268
+ }
269
+ const record = validateRecord(parsed, entry);
270
+ if (record.requestHash !== requestHash) fail('DELEGATION_RECORD_CORRUPT', 500, 'record does not belong to its request path', { entry });
271
+ if (expected) {
272
+ if (JSON.stringify(record.scope) !== JSON.stringify(expected.scope)) {
273
+ fail('DELEGATION_RECORD_CORRUPT', 500, 'record scope does not match its request path', { entry });
274
+ }
275
+ if (record.requestId !== expected.requestId) {
276
+ fail('DELEGATION_RECORD_CORRUPT', 500, 'record requestId does not match its request path', { entry });
277
+ }
278
+ }
279
+ return { status: 'ok', record };
280
+ }
281
+
282
+ // Every entry in the records directory must be a well-named regular file;
283
+ // anything else is tampering and fails loudly — never a silent empty list.
284
+ function scanRecordNames() {
285
+ const names = f.readdirSync(recordsDir).sort();
286
+ for (const name of names) {
287
+ if (!NAME_RE.test(name)) {
288
+ fail('DELEGATION_STORE_DIR_INVALID', 500, 'records directory contains an unexpected entry', { entry: name });
289
+ }
290
+ }
291
+ return names;
292
+ }
293
+
294
+ function cleanupOwnTmp(tmpPath) {
295
+ const resolved = path.resolve(tmpPath);
296
+ if (resolved !== tmpPath || !resolved.startsWith(tmpDir + path.sep) || path.basename(resolved).includes(path.sep)) {
297
+ return; // not provably this call's own temp file inside the dedicated root: leave it alone
298
+ }
299
+ try { f.unlinkSync(resolved); } catch (error) { if (error && error.code !== 'ENOENT') throw error; }
300
+ }
301
+
302
+ function returnCopy(record) {
303
+ return JSON.parse(JSON.stringify(record));
304
+ }
305
+
306
+ function requireBoundScope(scope) {
307
+ const valid = validateScope(scope);
308
+ if (valid.storeScope !== storeScope) {
309
+ fail('DELEGATION_STORE_SCOPE_MISMATCH', 400, 'this ledger is bound to a different storeScope');
310
+ }
311
+ return valid;
312
+ }
313
+
314
+ // Every success a caller could act on (a later dispatch phase starts from
315
+ // these IDs) must pass the commit fence within the same reserve() call:
316
+ // fresh commits, replays of existing records, and EEXIST losses to an
317
+ // identical winner are all fenced. The fence either syncs the directory
318
+ // entry, discloses that the platform primitive is unavailable, or fails —
319
+ // a record whose fence never succeeded must not be returned as success
320
+ // later without the fence being re-attempted. The record is never
321
+ // rewritten by this path, so a retry after recovery replays the same IDs.
322
+ function commitFence(after) {
323
+ try {
324
+ directoryFsync = dirFsync(recordsDir);
325
+ } catch (error) {
326
+ fail('DELEGATION_DIR_FSYNC_FAILED', 500, `reservation ${after}: commit fence (directory fsync) failed; the record stays untouched and a retry after recovery returns the same ids`, { reason: error && error.code });
327
+ }
328
+ }
329
+
330
+ function reserve(input = {}) {
331
+ const scope = requireBoundScope(validateScope(input.scope));
332
+ if (!isCleanString(input.requestId, 256)) fail('DELEGATION_REQUEST_INVALID', 400, 'requestId must be a non-empty string without control characters');
333
+ const requestId = input.requestId;
334
+ if (typeof input.payloadHash !== 'string' || !HASH_RE.test(input.payloadHash.toLowerCase())) {
335
+ fail('DELEGATION_PAYLOAD_HASH_INVALID', 400, 'payloadHash must be the server-computed canonical command sha256 hex');
336
+ }
337
+ const payloadHash = input.payloadHash.toLowerCase();
338
+ const workspace = validateWorkspace(input.workspace);
339
+ const selection = validateSelection(input.selection);
340
+ const binding = canonicalBinding(scope, requestId, payloadHash, workspace, selection);
341
+ const requestHash = requestHashOf(scope, requestId);
342
+
343
+ const existing = readRecord(requestHash, { scope, requestId });
344
+ if (existing.status === 'ok') {
345
+ if (sameBinding(existing.record, binding)) {
346
+ commitFence('replay');
347
+ return returnCopy(existing.record);
348
+ }
349
+ fail('DELEGATION_PAYLOAD_CONFLICT', 409, `a reservation already exists for this requestId with a different ${bindingDifference(existing.record, binding)}`);
350
+ }
351
+
352
+ const record = {
353
+ schema: SCHEMA, version: VERSION,
354
+ delegationId: newDelegationId(), childSessionId: newChildSessionId(),
355
+ scope, requestId, requestHash, payloadHash,
356
+ workspace, selection,
357
+ ...(input.context ? { context: require('./parent-context').validateParentContext(typeof input.context === 'function' ? input.context() : input.context) } : {}),
358
+ issuedAt: new Date().toISOString(),
359
+ source: SOURCE, basis: BASIS, phase: PHASE, outcome: OUTCOME,
360
+ sdkRunIdClaimed: false, inputCausalityClaimed: false,
361
+ };
362
+ const payload = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
363
+ tmpCounter += 1;
364
+ const tmpPath = path.join(tmpDir, `${process.pid}-${tmpCounter}-${crypto.randomUUID()}.tmp`);
365
+ let fd;
366
+ try {
367
+ fd = f.openSync(tmpPath, 'wx');
368
+ let written = 0;
369
+ while (written < payload.length) {
370
+ // writeSync returns THIS call's byte count: accumulate it and keep
371
+ // passing the true offset/length, so a legal short write never
372
+ // re-writes or skips bytes. A zero return means no progress at all
373
+ // and would otherwise spin forever — fail on the first one.
374
+ const bytesWritten = f.writeSync(fd, payload, written, payload.length - written);
375
+ if (!(bytesWritten > 0)) {
376
+ const noProgress = new Error('writeSync returned no progress');
377
+ noProgress.code = 'write-zero-progress';
378
+ throw noProgress;
379
+ }
380
+ written += bytesWritten;
381
+ }
382
+ f.fsyncSync(fd);
383
+ } catch (error) {
384
+ if (fd !== undefined) { try { f.closeSync(fd); } catch { /* already closed */ } }
385
+ try { cleanupOwnTmp(tmpPath); } catch { /* failure below is the honest answer */ }
386
+ fail('DELEGATION_WRITE_FAILED', 500, 'reservation record could not be written durably; nothing was reserved', { reason: error && error.code });
387
+ }
388
+ try { f.closeSync(fd); } catch { /* data already fsynced; close failure is not a durability loss */ }
389
+ try {
390
+ f.linkSync(tmpPath, recordPathOf(requestHash));
391
+ } catch (error) {
392
+ try { cleanupOwnTmp(tmpPath); } catch { /* reported below */ }
393
+ if (error && error.code === 'EEXIST') {
394
+ const winner = readRecord(requestHash, { scope, requestId });
395
+ if (winner.status !== 'ok') {
396
+ fail('DELEGATION_RECORD_CORRUPT', 500, 'concurrent reservation record exists but cannot be validated', { entry: `${requestHash}.json` });
397
+ }
398
+ if (sameBinding(winner.record, binding)) {
399
+ commitFence('lost-race');
400
+ return returnCopy(winner.record);
401
+ }
402
+ fail('DELEGATION_PAYLOAD_CONFLICT', 409, `a concurrent reservation won this requestId with a different ${bindingDifference(winner.record, binding)}`);
403
+ }
404
+ fail('DELEGATION_COMMIT_FAILED', 500, 'atomic reservation commit is unavailable on this platform/path; nothing was reserved', { reason: error && error.code });
405
+ }
406
+ // The link is visible and the content was fsynced before it. The commit
407
+ // fence syncs the directory entry (or discloses the capability gap via
408
+ // describe()); an unexpected failure surfaces as an error while the
409
+ // record itself stays intact — a retry re-attempts the fence and then
410
+ // replays the same IDs.
411
+ try {
412
+ commitFence('commit');
413
+ } catch (error) {
414
+ try { cleanupOwnTmp(tmpPath); } catch { /* the fence failure is the honest answer */ }
415
+ throw error;
416
+ }
417
+ try { cleanupOwnTmp(tmpPath); } catch (error) {
418
+ fail('DELEGATION_TMP_CLEANUP_FAILED', 500, 'reservation committed but its temp file could not be removed', { reason: error && error.code });
419
+ }
420
+ return returnCopy(record);
421
+ }
422
+
423
+ function getByRequest(scope, requestId) {
424
+ const valid = requireBoundScope(scope);
425
+ if (!isCleanString(requestId, 256)) fail('DELEGATION_REQUEST_INVALID', 400, 'requestId must be a non-empty string without control characters');
426
+ const found = readRecord(requestHashOf(valid, requestId), { scope: valid, requestId });
427
+ return found.status === 'ok' ? returnCopy(found.record) : null;
428
+ }
429
+
430
+ function getById(scope, delegationId) {
431
+ const valid = requireBoundScope(scope);
432
+ if (typeof delegationId !== 'string' || !UUID_RE.test(delegationId)) {
433
+ fail('DELEGATION_ID_INVALID', 400, 'delegationId must be a UUID issued by this ledger');
434
+ }
435
+ for (const name of scanRecordNames()) {
436
+ const requestHash = name.slice(0, -'.json'.length);
437
+ const found = readRecord(requestHash, null);
438
+ if (found.record.delegationId === delegationId) {
439
+ if (JSON.stringify(found.record.scope) !== JSON.stringify(valid)) {
440
+ fail('DELEGATION_SCOPE_MISMATCH', 403, 'this delegationId belongs to a different scope', { delegationId });
441
+ }
442
+ return returnCopy(found.record);
443
+ }
444
+ }
445
+ return null;
446
+ }
447
+
448
+ function list(scope) {
449
+ const valid = requireBoundScope(scope);
450
+ const records = [];
451
+ for (const name of scanRecordNames()) {
452
+ const found = readRecord(name.slice(0, -'.json'.length), null);
453
+ if (JSON.stringify(found.record.scope) === JSON.stringify(valid)) records.push(found.record);
454
+ }
455
+ records.sort((a, b) => a.issuedAt.localeCompare(b.issuedAt) || a.delegationId.localeCompare(b.delegationId));
456
+ return records.map(returnCopy);
457
+ }
458
+
459
+ return {
460
+ reserve,
461
+ getByRequest,
462
+ getById,
463
+ list,
464
+ describe() {
465
+ return { schema: SCHEMA, version: VERSION, root: rootDir, storeScope, directoryFsync };
466
+ },
467
+ };
468
+ }
469
+
470
+ module.exports = {
471
+ createDelegationStore,
472
+ DELEGATION_SCHEMA: SCHEMA,
473
+ DELEGATION_VERSION: VERSION,
474
+ };