@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,357 +1,357 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const crypto = require('crypto');
6
-
7
- // Durable phase/claim facts for Workbench delegation commands (the mutable
8
- // companion of the immutable reservation ledger in delegation-store.js).
9
- //
10
- // Every fact is a write-once JSON file inside the delegation's own directory
11
- // <root>/phases/<delegationId>/, published with the same discipline as the
12
- // ledger: tmp file + fsync + fs.link (create-if-absent) + directory-entry
13
- // fence. Nothing here ever rewrites the reservation record; a fact that
14
- // already exists is either an identical idempotent replay or an explicit
15
- // conflict, never a silent overwrite.
16
- //
17
- // child-created.json the trusted factory created the reserved child
18
- // child-create-failed.json creation/persistence failed; no model input
19
- // claim.json one owner claimed the dispatch attempt (the
20
- // exclusive file IS the cross-process mutex: a
21
- // second process or Service loses with EEXIST)
22
- // outcome.json the claim owner's proven outcome. A claim without
23
- // an outcome can never be proven "not sent" by an
24
- // outside observer: it reads as dispatch-unknown and
25
- // no automatic re-input may ever run.
26
- //
27
- // The store never sees or stores prompt bodies, credentials or env; fact
28
- // payloads carry only ids, fixed codes and timestamps.
29
-
30
- const SCHEMA = 'workbench-delegation-phase/v1';
31
- const VERSION = 1;
32
- const FACT_NAMES = Object.freeze(['child-created', 'child-create-failed', 'claim', 'outcome']);
33
- const OUTCOMES = new Set(['dispatched', 'dispatch-unknown']);
34
- 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}$/;
35
- const SESSION_ID_RE = /^sess-[0-9a-f]{12}$/;
36
- const DIR_FSYNC_UNSUPPORTED = new Set(['EPERM', 'EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EACCES']);
37
-
38
- function fail(code, status, message, extra) {
39
- const error = new Error(message);
40
- error.code = code;
41
- error.status = status;
42
- if (extra) Object.assign(error, extra);
43
- throw error;
44
- }
45
-
46
- function isCleanString(value, max) {
47
- if (typeof value !== 'string' || !value.length || value.length > max) return false;
48
- for (let k = 0; k < value.length; k += 1) {
49
- const code = value.charCodeAt(k);
50
- if (code < 0x20 || code === 0x7f) return false;
51
- }
52
- return true;
53
- }
54
-
55
- function defaultDirFsync(f, dirPath) {
56
- let fd;
57
- try {
58
- fd = f.openSync(dirPath, 'r');
59
- f.fsyncSync(fd);
60
- return 'supported';
61
- } catch (error) {
62
- if (DIR_FSYNC_UNSUPPORTED.has(error && error.code)) return 'unsupported';
63
- throw error;
64
- } finally {
65
- if (fd !== undefined) { try { f.closeSync(fd); } catch { /* closed or unusable */ } }
66
- }
67
- }
68
-
69
- function createDelegationPhaseStore(options = {}) {
70
- const { root, storeScope } = options || {};
71
- const f = options.fsx || fs;
72
- const dirFsync = typeof f.dirFsync === 'function' ? f.dirFsync.bind(f) : dirPath => defaultDirFsync(f, dirPath);
73
- if (typeof root !== 'string' || !root.trim() || !path.isAbsolute(root)) {
74
- fail('DELEGATION_STORE_ROOT_INVALID', 400, 'root must be an absolute server-owned directory');
75
- }
76
- if (!UUID_RE.test(storeScope || '')) {
77
- fail('DELEGATION_STORE_SCOPE_INVALID', 400, 'storeScope must be a canonical lowercase UUID v4');
78
- }
79
- const rootDir = path.resolve(root);
80
- const phasesDir = path.join(rootDir, 'phases');
81
- const tmpDir = path.join(rootDir, 'tmp');
82
-
83
- function ensureRealDir(dirPath, what) {
84
- try {
85
- f.mkdirSync(dirPath, { recursive: true });
86
- } catch (error) {
87
- if (!(error && (error.code === 'EEXIST' || error.code === 'ENOTDIR'))) throw error;
88
- }
89
- const st = f.lstatSync(dirPath);
90
- if (st.isSymbolicLink()) fail('DELEGATION_STORE_PATH_UNSAFE', 400, `${what} must not be a symbolic link`, { path: what });
91
- if (!st.isDirectory()) fail('DELEGATION_STORE_PATH_UNSAFE', 400, `${what} must be a real directory`, { path: what });
92
- }
93
-
94
- ensureRealDir(rootDir, 'phase store root');
95
- ensureRealDir(phasesDir, 'phases directory');
96
- ensureRealDir(tmpDir, 'tmp directory');
97
-
98
- let directoryFsync = 'unknown';
99
- let tmpCounter = 0;
100
- // Claims this store instance currently owns and has not yet settled with an
101
- // outcome. Only meaningful in-process: it lets a same-instance concurrent
102
- // caller see "dispatching" instead of the conservative dispatch-unknown.
103
- const liveClaims = new Map();
104
-
105
- const dirOf = delegationId => path.join(phasesDir, delegationId);
106
-
107
- function requireDelegationId(delegationId) {
108
- if (typeof delegationId !== 'string' || !UUID_RE.test(delegationId)) {
109
- fail('DELEGATION_ID_INVALID', 400, 'delegationId must be a UUID issued by the ledger');
110
- }
111
- return delegationId;
112
- }
113
-
114
- // Write-once publish of one fact file. Returns 'written' for a fresh
115
- // commit, 'present' for an identical replay. Any disagreement with the
116
- // existing bytes is an explicit conflict; the file itself never changes.
117
- function publishFact(delegationId, name, payload) {
118
- const dir = dirOf(delegationId);
119
- try {
120
- f.mkdirSync(dir, { recursive: true });
121
- } catch (error) {
122
- fail('DELEGATION_PHASE_WRITE_FAILED', 500, 'delegation phase directory could not be created', { reason: error && error.code });
123
- }
124
- const st = f.lstatSync(dir);
125
- if (st.isSymbolicLink() || !st.isDirectory()) {
126
- fail('DELEGATION_STORE_PATH_UNSAFE', 500, 'delegation phase path must be a real directory');
127
- }
128
- const target = path.join(dir, `${name}.json`);
129
- let existing;
130
- try { existing = f.lstatSync(target); } catch (error) {
131
- if (!(error && error.code === 'ENOENT')) throw error;
132
- existing = null;
133
- }
134
- const body = Buffer.from(`${JSON.stringify(payload)}\n`, 'utf8');
135
- if (existing) {
136
- const known = readFact(delegationId, name);
137
- if (JSON.stringify(stripAt(known)) === JSON.stringify(stripAt(payload))) return 'present';
138
- fail('DELEGATION_FACT_CONFLICT', 409, `the delegation ${name} fact already exists with different content`);
139
- }
140
- tmpCounter += 1;
141
- const tmpPath = path.join(tmpDir, `${process.pid}-${tmpCounter}-${crypto.randomUUID()}.tmp`);
142
- const cleanup = () => {
143
- try { f.unlinkSync(tmpPath); } catch (error) { if (error && error.code !== 'ENOENT') throw error; }
144
- };
145
- let fd;
146
- try {
147
- fd = f.openSync(tmpPath, 'wx');
148
- let written = 0;
149
- while (written < body.length) {
150
- const progress = f.writeSync(fd, body, written, body.length - written);
151
- if (!(progress > 0)) {
152
- const noProgress = new Error('writeSync returned no progress');
153
- noProgress.code = 'write-zero-progress';
154
- throw noProgress;
155
- }
156
- written += progress;
157
- }
158
- f.fsyncSync(fd);
159
- } catch (error) {
160
- if (fd !== undefined) { try { f.closeSync(fd); } catch { /* reported below */ } }
161
- try { cleanup(); } catch { /* reported below */ }
162
- fail('DELEGATION_PHASE_WRITE_FAILED', 500, `delegation ${name} fact could not be written durably; nothing was claimed or dispatched`, { reason: error && error.code });
163
- }
164
- try { f.closeSync(fd); } catch { /* data already fsynced */ }
165
- try {
166
- f.linkSync(tmpPath, target);
167
- } catch (error) {
168
- try { cleanup(); } catch { /* reported below */ }
169
- if (error && error.code === 'EEXIST') {
170
- const known = readFact(delegationId, name);
171
- if (JSON.stringify(stripAt(known)) === JSON.stringify(stripAt(payload))) return 'present';
172
- fail('DELEGATION_FACT_CONFLICT', 409, `a concurrent ${name} fact exists with different content`);
173
- }
174
- fail('DELEGATION_PHASE_COMMIT_FAILED', 500, 'delegation phase commit is unavailable on this platform/path', { reason: error && error.code });
175
- }
176
- try {
177
- directoryFsync = dirFsync(dir);
178
- } catch (error) {
179
- try { cleanup(); } catch { /* the fence failure is the honest answer */ }
180
- fail('DELEGATION_DIR_FSYNC_FAILED', 500, `delegation ${name} fact commit fence (directory fsync) failed; the fact stays untouched`, { reason: error && error.code });
181
- }
182
- try { cleanup(); } catch (error) {
183
- fail('DELEGATION_TMP_CLEANUP_FAILED', 500, 'delegation phase committed but its temp file could not be removed', { reason: error && error.code });
184
- }
185
- return 'written';
186
- }
187
-
188
- // stripAt removes volatile fields (at) so a replay of the same fact with a
189
- // fresh timestamp still counts as the same fact.
190
- function stripAt(payload) {
191
- const copy = { ...payload };
192
- delete copy.at;
193
- return copy;
194
- }
195
-
196
- function readFact(delegationId, name) {
197
- const file = path.join(dirOf(delegationId), `${name}.json`);
198
- let st;
199
- try { st = f.lstatSync(file); } catch (error) {
200
- if (error && error.code === 'ENOENT') return null;
201
- throw error;
202
- }
203
- if (st.isSymbolicLink() || !st.isFile()) {
204
- fail('DELEGATION_PHASE_INVALID', 500, `delegation ${name} fact entry is not a regular file`, { entry: `${delegationId}/${name}.json` });
205
- }
206
- let parsed;
207
- try {
208
- parsed = JSON.parse(f.readFileSync(file, 'utf8'));
209
- } catch (error) {
210
- if (error instanceof SyntaxError) parsed = null;
211
- else throw error;
212
- }
213
- if (!parsed || typeof parsed !== 'object' || parsed.schema !== SCHEMA || parsed.version !== VERSION || parsed.delegationId !== delegationId) {
214
- fail('DELEGATION_PHASE_CORRUPT', 500, `stored delegation ${name} fact is invalid`, { entry: `${delegationId}/${name}.json` });
215
- }
216
- return parsed;
217
- }
218
-
219
- // Read the delegation's phase directory; unexpected entries are tampering
220
- // and fail loudly instead of yielding a silent partial state. Returns the
221
- // bare fact names (without the .json suffix every fact file must carry).
222
- function factNames(delegationId) {
223
- let entries;
224
- try {
225
- entries = f.readdirSync(dirOf(delegationId)).sort();
226
- } catch (error) {
227
- if (error && error.code === 'ENOENT') return [];
228
- throw error;
229
- }
230
- const names = [];
231
- for (const entry of entries) {
232
- const base = entry.endsWith('.json') ? entry.slice(0, -'.json'.length) : null;
233
- if (!base || !FACT_NAMES.includes(base)) {
234
- fail('DELEGATION_PHASE_DIR_INVALID', 500, 'delegation phase directory contains an unexpected entry', { entry });
235
- }
236
- names.push(base);
237
- }
238
- return names;
239
- }
240
-
241
- function recordChildCreated(input = {}) {
242
- const delegationId = requireDelegationId(input.delegationId);
243
- if (!SESSION_ID_RE.test(input.childSessionId || '')) {
244
- fail('DELEGATION_CHILD_INVALID', 400, 'childSessionId must be the ledger-reserved session id');
245
- }
246
- if (!isCleanString(input.workspaceRef, 128)) {
247
- fail('DELEGATION_WORKSPACE_INVALID', 400, 'workspaceRef must be a non-empty string');
248
- }
249
- publishFact(delegationId, 'child-created', {
250
- schema: SCHEMA, version: VERSION, delegationId,
251
- childSessionId: input.childSessionId, workspaceRef: input.workspaceRef,
252
- at: new Date().toISOString(),
253
- });
254
- return true;
255
- }
256
-
257
- function recordChildCreateFailed(input = {}) {
258
- const delegationId = requireDelegationId(input.delegationId);
259
- if (!isCleanString(input.code, 128)) {
260
- fail('DELEGATION_PHASE_INVALID', 400, 'code must be a non-empty string');
261
- }
262
- publishFact(delegationId, 'child-create-failed', {
263
- schema: SCHEMA, version: VERSION, delegationId, code: input.code,
264
- at: new Date().toISOString(),
265
- });
266
- return true;
267
- }
268
-
269
- // The dispatch claim. Exactly one caller across processes wins the
270
- // exclusive create; every loser gets the existing claim back and MUST NOT
271
- // send any model input.
272
- function claimDispatch(input = {}) {
273
- const delegationId = requireDelegationId(input.delegationId);
274
- if (!UUID_RE.test(input.attemptId || '')) {
275
- fail('DELEGATION_PHASE_INVALID', 400, 'attemptId must be a UUID');
276
- }
277
- if (!Number.isInteger(input.ownerPid) || input.ownerPid <= 0) {
278
- fail('DELEGATION_PHASE_INVALID', 400, 'ownerPid must be a positive integer');
279
- }
280
- if (!UUID_RE.test(input.ownerEpoch || '')) {
281
- fail('DELEGATION_PHASE_INVALID', 400, 'ownerEpoch must be a UUID');
282
- }
283
- const result = publishFact(delegationId, 'claim', {
284
- schema: SCHEMA, version: VERSION, delegationId,
285
- attemptId: input.attemptId, ownerPid: input.ownerPid, ownerEpoch: input.ownerEpoch,
286
- at: new Date().toISOString(),
287
- });
288
- if (result === 'written') {
289
- liveClaims.set(delegationId, input.attemptId);
290
- return { claimed: true, claim: readFact(delegationId, 'claim') };
291
- }
292
- const claim = readFact(delegationId, 'claim');
293
- return { claimed: false, claim };
294
- }
295
-
296
- function recordOutcome(input = {}) {
297
- const delegationId = requireDelegationId(input.delegationId);
298
- if (!UUID_RE.test(input.attemptId || '')) {
299
- fail('DELEGATION_PHASE_INVALID', 400, 'attemptId must be a UUID');
300
- }
301
- if (!OUTCOMES.has(input.outcome)) {
302
- fail('DELEGATION_PHASE_INVALID', 400, 'outcome must be dispatched or dispatch-unknown');
303
- }
304
- publishFact(delegationId, 'outcome', {
305
- schema: SCHEMA, version: VERSION, delegationId,
306
- attemptId: input.attemptId, outcome: input.outcome,
307
- at: new Date().toISOString(),
308
- });
309
- liveClaims.delete(delegationId);
310
- return true;
311
- }
312
-
313
- // A failed outcome publish can leave the durable claim unsettled even in a
314
- // Service that is still alive. The owner must drop its in-process liveness
315
- // marker so reads report the conservative dispatch-unknown verdict instead
316
- // of an optimistic "dispatching" state. This changes no durable fact.
317
- function abandonLiveClaim(input = {}) {
318
- const delegationId = requireDelegationId(input.delegationId);
319
- if (!UUID_RE.test(input.attemptId || '')) {
320
- fail('DELEGATION_PHASE_INVALID', 400, 'attemptId must be a UUID');
321
- }
322
- if (liveClaims.get(delegationId) === input.attemptId) liveClaims.delete(delegationId);
323
- return true;
324
- }
325
-
326
- function stateOf(delegationId) {
327
- requireDelegationId(delegationId);
328
- const names = factNames(delegationId);
329
- const state = { childCreated: null, childCreateFailed: null, claim: null, outcome: null };
330
- for (const name of names) {
331
- if (name === 'child-created') state.childCreated = readFact(delegationId, name);
332
- else if (name === 'child-create-failed') state.childCreateFailed = readFact(delegationId, name);
333
- else if (name === 'claim') state.claim = readFact(delegationId, name);
334
- else if (name === 'outcome') state.outcome = readFact(delegationId, name);
335
- }
336
- return state;
337
- }
338
-
339
- function isClaimLive(delegationId) {
340
- return liveClaims.has(delegationId);
341
- }
342
-
343
- return {
344
- recordChildCreated,
345
- recordChildCreateFailed,
346
- claimDispatch,
347
- recordOutcome,
348
- abandonLiveClaim,
349
- stateOf,
350
- isClaimLive,
351
- describe() {
352
- return { schema: SCHEMA, version: VERSION, root: rootDir, storeScope, directoryFsync };
353
- },
354
- };
355
- }
356
-
357
- module.exports = { createDelegationPhaseStore };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+
7
+ // Durable phase/claim facts for Workbench delegation commands (the mutable
8
+ // companion of the immutable reservation ledger in delegation-store.js).
9
+ //
10
+ // Every fact is a write-once JSON file inside the delegation's own directory
11
+ // <root>/phases/<delegationId>/, published with the same discipline as the
12
+ // ledger: tmp file + fsync + fs.link (create-if-absent) + directory-entry
13
+ // fence. Nothing here ever rewrites the reservation record; a fact that
14
+ // already exists is either an identical idempotent replay or an explicit
15
+ // conflict, never a silent overwrite.
16
+ //
17
+ // child-created.json the trusted factory created the reserved child
18
+ // child-create-failed.json creation/persistence failed; no model input
19
+ // claim.json one owner claimed the dispatch attempt (the
20
+ // exclusive file IS the cross-process mutex: a
21
+ // second process or Service loses with EEXIST)
22
+ // outcome.json the claim owner's proven outcome. A claim without
23
+ // an outcome can never be proven "not sent" by an
24
+ // outside observer: it reads as dispatch-unknown and
25
+ // no automatic re-input may ever run.
26
+ //
27
+ // The store never sees or stores prompt bodies, credentials or env; fact
28
+ // payloads carry only ids, fixed codes and timestamps.
29
+
30
+ const SCHEMA = 'workbench-delegation-phase/v1';
31
+ const VERSION = 1;
32
+ const FACT_NAMES = Object.freeze(['child-created', 'child-create-failed', 'claim', 'outcome']);
33
+ const OUTCOMES = new Set(['dispatched', 'dispatch-unknown']);
34
+ 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}$/;
35
+ const SESSION_ID_RE = /^sess-[0-9a-f]{12}$/;
36
+ const DIR_FSYNC_UNSUPPORTED = new Set(['EPERM', 'EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EACCES']);
37
+
38
+ function fail(code, status, message, extra) {
39
+ const error = new Error(message);
40
+ error.code = code;
41
+ error.status = status;
42
+ if (extra) Object.assign(error, extra);
43
+ throw error;
44
+ }
45
+
46
+ function isCleanString(value, max) {
47
+ if (typeof value !== 'string' || !value.length || value.length > max) return false;
48
+ for (let k = 0; k < value.length; k += 1) {
49
+ const code = value.charCodeAt(k);
50
+ if (code < 0x20 || code === 0x7f) return false;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ function defaultDirFsync(f, dirPath) {
56
+ let fd;
57
+ try {
58
+ fd = f.openSync(dirPath, 'r');
59
+ f.fsyncSync(fd);
60
+ return 'supported';
61
+ } catch (error) {
62
+ if (DIR_FSYNC_UNSUPPORTED.has(error && error.code)) return 'unsupported';
63
+ throw error;
64
+ } finally {
65
+ if (fd !== undefined) { try { f.closeSync(fd); } catch { /* closed or unusable */ } }
66
+ }
67
+ }
68
+
69
+ function createDelegationPhaseStore(options = {}) {
70
+ const { root, storeScope } = options || {};
71
+ const f = options.fsx || fs;
72
+ const dirFsync = typeof f.dirFsync === 'function' ? f.dirFsync.bind(f) : dirPath => defaultDirFsync(f, dirPath);
73
+ if (typeof root !== 'string' || !root.trim() || !path.isAbsolute(root)) {
74
+ fail('DELEGATION_STORE_ROOT_INVALID', 400, 'root must be an absolute server-owned directory');
75
+ }
76
+ if (!UUID_RE.test(storeScope || '')) {
77
+ fail('DELEGATION_STORE_SCOPE_INVALID', 400, 'storeScope must be a canonical lowercase UUID v4');
78
+ }
79
+ const rootDir = path.resolve(root);
80
+ const phasesDir = path.join(rootDir, 'phases');
81
+ const tmpDir = path.join(rootDir, 'tmp');
82
+
83
+ function ensureRealDir(dirPath, what) {
84
+ try {
85
+ f.mkdirSync(dirPath, { recursive: true });
86
+ } catch (error) {
87
+ if (!(error && (error.code === 'EEXIST' || error.code === 'ENOTDIR'))) throw error;
88
+ }
89
+ const st = f.lstatSync(dirPath);
90
+ if (st.isSymbolicLink()) fail('DELEGATION_STORE_PATH_UNSAFE', 400, `${what} must not be a symbolic link`, { path: what });
91
+ if (!st.isDirectory()) fail('DELEGATION_STORE_PATH_UNSAFE', 400, `${what} must be a real directory`, { path: what });
92
+ }
93
+
94
+ ensureRealDir(rootDir, 'phase store root');
95
+ ensureRealDir(phasesDir, 'phases directory');
96
+ ensureRealDir(tmpDir, 'tmp directory');
97
+
98
+ let directoryFsync = 'unknown';
99
+ let tmpCounter = 0;
100
+ // Claims this store instance currently owns and has not yet settled with an
101
+ // outcome. Only meaningful in-process: it lets a same-instance concurrent
102
+ // caller see "dispatching" instead of the conservative dispatch-unknown.
103
+ const liveClaims = new Map();
104
+
105
+ const dirOf = delegationId => path.join(phasesDir, delegationId);
106
+
107
+ function requireDelegationId(delegationId) {
108
+ if (typeof delegationId !== 'string' || !UUID_RE.test(delegationId)) {
109
+ fail('DELEGATION_ID_INVALID', 400, 'delegationId must be a UUID issued by the ledger');
110
+ }
111
+ return delegationId;
112
+ }
113
+
114
+ // Write-once publish of one fact file. Returns 'written' for a fresh
115
+ // commit, 'present' for an identical replay. Any disagreement with the
116
+ // existing bytes is an explicit conflict; the file itself never changes.
117
+ function publishFact(delegationId, name, payload) {
118
+ const dir = dirOf(delegationId);
119
+ try {
120
+ f.mkdirSync(dir, { recursive: true });
121
+ } catch (error) {
122
+ fail('DELEGATION_PHASE_WRITE_FAILED', 500, 'delegation phase directory could not be created', { reason: error && error.code });
123
+ }
124
+ const st = f.lstatSync(dir);
125
+ if (st.isSymbolicLink() || !st.isDirectory()) {
126
+ fail('DELEGATION_STORE_PATH_UNSAFE', 500, 'delegation phase path must be a real directory');
127
+ }
128
+ const target = path.join(dir, `${name}.json`);
129
+ let existing;
130
+ try { existing = f.lstatSync(target); } catch (error) {
131
+ if (!(error && error.code === 'ENOENT')) throw error;
132
+ existing = null;
133
+ }
134
+ const body = Buffer.from(`${JSON.stringify(payload)}\n`, 'utf8');
135
+ if (existing) {
136
+ const known = readFact(delegationId, name);
137
+ if (JSON.stringify(stripAt(known)) === JSON.stringify(stripAt(payload))) return 'present';
138
+ fail('DELEGATION_FACT_CONFLICT', 409, `the delegation ${name} fact already exists with different content`);
139
+ }
140
+ tmpCounter += 1;
141
+ const tmpPath = path.join(tmpDir, `${process.pid}-${tmpCounter}-${crypto.randomUUID()}.tmp`);
142
+ const cleanup = () => {
143
+ try { f.unlinkSync(tmpPath); } catch (error) { if (error && error.code !== 'ENOENT') throw error; }
144
+ };
145
+ let fd;
146
+ try {
147
+ fd = f.openSync(tmpPath, 'wx');
148
+ let written = 0;
149
+ while (written < body.length) {
150
+ const progress = f.writeSync(fd, body, written, body.length - written);
151
+ if (!(progress > 0)) {
152
+ const noProgress = new Error('writeSync returned no progress');
153
+ noProgress.code = 'write-zero-progress';
154
+ throw noProgress;
155
+ }
156
+ written += progress;
157
+ }
158
+ f.fsyncSync(fd);
159
+ } catch (error) {
160
+ if (fd !== undefined) { try { f.closeSync(fd); } catch { /* reported below */ } }
161
+ try { cleanup(); } catch { /* reported below */ }
162
+ fail('DELEGATION_PHASE_WRITE_FAILED', 500, `delegation ${name} fact could not be written durably; nothing was claimed or dispatched`, { reason: error && error.code });
163
+ }
164
+ try { f.closeSync(fd); } catch { /* data already fsynced */ }
165
+ try {
166
+ f.linkSync(tmpPath, target);
167
+ } catch (error) {
168
+ try { cleanup(); } catch { /* reported below */ }
169
+ if (error && error.code === 'EEXIST') {
170
+ const known = readFact(delegationId, name);
171
+ if (JSON.stringify(stripAt(known)) === JSON.stringify(stripAt(payload))) return 'present';
172
+ fail('DELEGATION_FACT_CONFLICT', 409, `a concurrent ${name} fact exists with different content`);
173
+ }
174
+ fail('DELEGATION_PHASE_COMMIT_FAILED', 500, 'delegation phase commit is unavailable on this platform/path', { reason: error && error.code });
175
+ }
176
+ try {
177
+ directoryFsync = dirFsync(dir);
178
+ } catch (error) {
179
+ try { cleanup(); } catch { /* the fence failure is the honest answer */ }
180
+ fail('DELEGATION_DIR_FSYNC_FAILED', 500, `delegation ${name} fact commit fence (directory fsync) failed; the fact stays untouched`, { reason: error && error.code });
181
+ }
182
+ try { cleanup(); } catch (error) {
183
+ fail('DELEGATION_TMP_CLEANUP_FAILED', 500, 'delegation phase committed but its temp file could not be removed', { reason: error && error.code });
184
+ }
185
+ return 'written';
186
+ }
187
+
188
+ // stripAt removes volatile fields (at) so a replay of the same fact with a
189
+ // fresh timestamp still counts as the same fact.
190
+ function stripAt(payload) {
191
+ const copy = { ...payload };
192
+ delete copy.at;
193
+ return copy;
194
+ }
195
+
196
+ function readFact(delegationId, name) {
197
+ const file = path.join(dirOf(delegationId), `${name}.json`);
198
+ let st;
199
+ try { st = f.lstatSync(file); } catch (error) {
200
+ if (error && error.code === 'ENOENT') return null;
201
+ throw error;
202
+ }
203
+ if (st.isSymbolicLink() || !st.isFile()) {
204
+ fail('DELEGATION_PHASE_INVALID', 500, `delegation ${name} fact entry is not a regular file`, { entry: `${delegationId}/${name}.json` });
205
+ }
206
+ let parsed;
207
+ try {
208
+ parsed = JSON.parse(f.readFileSync(file, 'utf8'));
209
+ } catch (error) {
210
+ if (error instanceof SyntaxError) parsed = null;
211
+ else throw error;
212
+ }
213
+ if (!parsed || typeof parsed !== 'object' || parsed.schema !== SCHEMA || parsed.version !== VERSION || parsed.delegationId !== delegationId) {
214
+ fail('DELEGATION_PHASE_CORRUPT', 500, `stored delegation ${name} fact is invalid`, { entry: `${delegationId}/${name}.json` });
215
+ }
216
+ return parsed;
217
+ }
218
+
219
+ // Read the delegation's phase directory; unexpected entries are tampering
220
+ // and fail loudly instead of yielding a silent partial state. Returns the
221
+ // bare fact names (without the .json suffix every fact file must carry).
222
+ function factNames(delegationId) {
223
+ let entries;
224
+ try {
225
+ entries = f.readdirSync(dirOf(delegationId)).sort();
226
+ } catch (error) {
227
+ if (error && error.code === 'ENOENT') return [];
228
+ throw error;
229
+ }
230
+ const names = [];
231
+ for (const entry of entries) {
232
+ const base = entry.endsWith('.json') ? entry.slice(0, -'.json'.length) : null;
233
+ if (!base || !FACT_NAMES.includes(base)) {
234
+ fail('DELEGATION_PHASE_DIR_INVALID', 500, 'delegation phase directory contains an unexpected entry', { entry });
235
+ }
236
+ names.push(base);
237
+ }
238
+ return names;
239
+ }
240
+
241
+ function recordChildCreated(input = {}) {
242
+ const delegationId = requireDelegationId(input.delegationId);
243
+ if (!SESSION_ID_RE.test(input.childSessionId || '')) {
244
+ fail('DELEGATION_CHILD_INVALID', 400, 'childSessionId must be the ledger-reserved session id');
245
+ }
246
+ if (!isCleanString(input.workspaceRef, 128)) {
247
+ fail('DELEGATION_WORKSPACE_INVALID', 400, 'workspaceRef must be a non-empty string');
248
+ }
249
+ publishFact(delegationId, 'child-created', {
250
+ schema: SCHEMA, version: VERSION, delegationId,
251
+ childSessionId: input.childSessionId, workspaceRef: input.workspaceRef,
252
+ at: new Date().toISOString(),
253
+ });
254
+ return true;
255
+ }
256
+
257
+ function recordChildCreateFailed(input = {}) {
258
+ const delegationId = requireDelegationId(input.delegationId);
259
+ if (!isCleanString(input.code, 128)) {
260
+ fail('DELEGATION_PHASE_INVALID', 400, 'code must be a non-empty string');
261
+ }
262
+ publishFact(delegationId, 'child-create-failed', {
263
+ schema: SCHEMA, version: VERSION, delegationId, code: input.code,
264
+ at: new Date().toISOString(),
265
+ });
266
+ return true;
267
+ }
268
+
269
+ // The dispatch claim. Exactly one caller across processes wins the
270
+ // exclusive create; every loser gets the existing claim back and MUST NOT
271
+ // send any model input.
272
+ function claimDispatch(input = {}) {
273
+ const delegationId = requireDelegationId(input.delegationId);
274
+ if (!UUID_RE.test(input.attemptId || '')) {
275
+ fail('DELEGATION_PHASE_INVALID', 400, 'attemptId must be a UUID');
276
+ }
277
+ if (!Number.isInteger(input.ownerPid) || input.ownerPid <= 0) {
278
+ fail('DELEGATION_PHASE_INVALID', 400, 'ownerPid must be a positive integer');
279
+ }
280
+ if (!UUID_RE.test(input.ownerEpoch || '')) {
281
+ fail('DELEGATION_PHASE_INVALID', 400, 'ownerEpoch must be a UUID');
282
+ }
283
+ const result = publishFact(delegationId, 'claim', {
284
+ schema: SCHEMA, version: VERSION, delegationId,
285
+ attemptId: input.attemptId, ownerPid: input.ownerPid, ownerEpoch: input.ownerEpoch,
286
+ at: new Date().toISOString(),
287
+ });
288
+ if (result === 'written') {
289
+ liveClaims.set(delegationId, input.attemptId);
290
+ return { claimed: true, claim: readFact(delegationId, 'claim') };
291
+ }
292
+ const claim = readFact(delegationId, 'claim');
293
+ return { claimed: false, claim };
294
+ }
295
+
296
+ function recordOutcome(input = {}) {
297
+ const delegationId = requireDelegationId(input.delegationId);
298
+ if (!UUID_RE.test(input.attemptId || '')) {
299
+ fail('DELEGATION_PHASE_INVALID', 400, 'attemptId must be a UUID');
300
+ }
301
+ if (!OUTCOMES.has(input.outcome)) {
302
+ fail('DELEGATION_PHASE_INVALID', 400, 'outcome must be dispatched or dispatch-unknown');
303
+ }
304
+ publishFact(delegationId, 'outcome', {
305
+ schema: SCHEMA, version: VERSION, delegationId,
306
+ attemptId: input.attemptId, outcome: input.outcome,
307
+ at: new Date().toISOString(),
308
+ });
309
+ liveClaims.delete(delegationId);
310
+ return true;
311
+ }
312
+
313
+ // A failed outcome publish can leave the durable claim unsettled even in a
314
+ // Service that is still alive. The owner must drop its in-process liveness
315
+ // marker so reads report the conservative dispatch-unknown verdict instead
316
+ // of an optimistic "dispatching" state. This changes no durable fact.
317
+ function abandonLiveClaim(input = {}) {
318
+ const delegationId = requireDelegationId(input.delegationId);
319
+ if (!UUID_RE.test(input.attemptId || '')) {
320
+ fail('DELEGATION_PHASE_INVALID', 400, 'attemptId must be a UUID');
321
+ }
322
+ if (liveClaims.get(delegationId) === input.attemptId) liveClaims.delete(delegationId);
323
+ return true;
324
+ }
325
+
326
+ function stateOf(delegationId) {
327
+ requireDelegationId(delegationId);
328
+ const names = factNames(delegationId);
329
+ const state = { childCreated: null, childCreateFailed: null, claim: null, outcome: null };
330
+ for (const name of names) {
331
+ if (name === 'child-created') state.childCreated = readFact(delegationId, name);
332
+ else if (name === 'child-create-failed') state.childCreateFailed = readFact(delegationId, name);
333
+ else if (name === 'claim') state.claim = readFact(delegationId, name);
334
+ else if (name === 'outcome') state.outcome = readFact(delegationId, name);
335
+ }
336
+ return state;
337
+ }
338
+
339
+ function isClaimLive(delegationId) {
340
+ return liveClaims.has(delegationId);
341
+ }
342
+
343
+ return {
344
+ recordChildCreated,
345
+ recordChildCreateFailed,
346
+ claimDispatch,
347
+ recordOutcome,
348
+ abandonLiveClaim,
349
+ stateOf,
350
+ isClaimLive,
351
+ describe() {
352
+ return { schema: SCHEMA, version: VERSION, root: rootDir, storeScope, directoryFsync };
353
+ },
354
+ };
355
+ }
356
+
357
+ module.exports = { createDelegationPhaseStore };