@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3

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 (120) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +636 -613
  4. package/apps/agent-terminal/app.js +125 -65
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +171 -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 +187 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +623 -519
  11. package/apps/ai-profile-settings-host/index.html +25 -25
  12. package/apps/ai-profile-settings-host/page.css +1 -1
  13. package/apps/ai-profile-settings-host/page.js +113 -113
  14. package/apps/minimal-test-host/README.md +15 -15
  15. package/apps/minimal-test-host/host.css +2 -2
  16. package/apps/minimal-test-host/host.js +35 -35
  17. package/apps/minimal-test-host/index.html +58 -58
  18. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  19. package/docs/sdk/FILE_LAYOUT.md +31 -31
  20. package/docs/sdk/INTEGRATION.md +126 -126
  21. package/docs/sdk/README.md +21 -21
  22. package/docs/sdk/SESSION_EVENTS.md +13 -13
  23. package/docs/sdk/openapi.yaml +8 -1
  24. package/package.json +1 -1
  25. package/packages/client/README.md +29 -29
  26. package/packages/client/index.d.ts +122 -122
  27. package/packages/client/index.js +260 -260
  28. package/packages/client/package.json +2 -2
  29. package/packages/contracts/README.md +9 -9
  30. package/packages/contracts/index.d.ts +519 -517
  31. package/packages/contracts/index.js +312 -312
  32. package/packages/contracts/package.json +1 -1
  33. package/packages/core/README.md +8 -8
  34. package/packages/core/index.d.ts +76 -76
  35. package/packages/core/index.js +222 -222
  36. package/packages/core/package.json +2 -2
  37. package/packages/server/README.md +17 -17
  38. package/packages/server/bin/agent-terminal-server.js +42 -42
  39. package/packages/server/bin/claude-workbench.js +225 -225
  40. package/packages/server/index.d.ts +171 -171
  41. package/packages/server/index.js +230 -230
  42. package/packages/server/lib/agent-discovery.js +133 -133
  43. package/packages/server/lib/agent-registry.js +143 -143
  44. package/packages/server/lib/agent-spawn-env.js +17 -17
  45. package/packages/server/lib/attachment-store.js +78 -78
  46. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  47. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  48. package/packages/server/lib/capture-settings-store.js +35 -35
  49. package/packages/server/lib/claude-history-recovery.js +87 -87
  50. package/packages/server/lib/cli-session-recovery.js +28 -28
  51. package/packages/server/lib/context-resolver.js +55 -55
  52. package/packages/server/lib/conversation-capture.js +51 -51
  53. package/packages/server/lib/delegation-phase-store.js +357 -357
  54. package/packages/server/lib/delegation-store.js +474 -468
  55. package/packages/server/lib/diagnostics.js +32 -32
  56. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  57. package/packages/server/lib/drivers/codex-driver.js +1442 -1442
  58. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  59. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  60. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  61. package/packages/server/lib/event-stream.js +138 -138
  62. package/packages/server/lib/folder-picker.js +623 -623
  63. package/packages/server/lib/http-handler.js +339 -339
  64. package/packages/server/lib/json-file.js +26 -26
  65. package/packages/server/lib/knowledge-context.js +33 -33
  66. package/packages/server/lib/model-detection.js +295 -295
  67. package/packages/server/lib/parent-context.js +44 -0
  68. package/packages/server/lib/profile-store.js +283 -283
  69. package/packages/server/lib/project-store.js +102 -102
  70. package/packages/server/lib/runtime-loader.js +120 -120
  71. package/packages/server/lib/session-archive-store.js +53 -53
  72. package/packages/server/lib/session-history.js +612 -612
  73. package/packages/server/lib/system-doctor.js +125 -125
  74. package/packages/server/lib/ui-preferences-store.js +48 -48
  75. package/packages/server/lib/workbench-service.js +1696 -1675
  76. package/packages/server/package.json +2 -2
  77. package/packages/ui/README.md +30 -30
  78. package/packages/ui/ai-profile-settings.d.ts +45 -45
  79. package/packages/ui/ai-profile-settings.js +283 -283
  80. package/packages/ui/claude-terminal.d.ts +31 -31
  81. package/packages/ui/claude-terminal.js +343 -343
  82. package/packages/ui/index.d.ts +18 -18
  83. package/packages/ui/index.js +51 -51
  84. package/packages/ui/package.json +2 -2
  85. package/packages/ui/theme.css +13 -13
  86. package/src/assets/claude-prompts.json +21 -21
  87. package/src/backend/http/ai-profile-routes.js +61 -61
  88. package/src/backend/http/claude-routes.js +170 -170
  89. package/src/backend/http/node-helpers.js +45 -45
  90. package/src/backend/index.js +21 -21
  91. package/src/backend/lib/ai-adapter.js +144 -144
  92. package/src/backend/lib/ai-profile-store.js +259 -259
  93. package/src/backend/lib/ai-vendor-presets.js +83 -83
  94. package/src/backend/lib/ai-workspace.js +140 -140
  95. package/src/backend/lib/automation-config.js +253 -253
  96. package/src/backend/lib/claude-cli-runner.js +2392 -2392
  97. package/src/backend/lib/data-dir.js +169 -169
  98. package/src/backend/lib/llm-client.js +176 -176
  99. package/src/backend/lib/model-context-windows.js +82 -82
  100. package/src/backend/lib/native-task-control.js +217 -217
  101. package/src/backend/lib/native-task-history-index.js +475 -475
  102. package/src/backend/lib/native-task-history-reader.js +526 -526
  103. package/src/backend/lib/native-task-messages.js +457 -457
  104. package/src/backend/lib/native-task-registry.js +325 -325
  105. package/src/backend/lib/native-task-runtime.js +242 -242
  106. package/src/backend/lib/native-task-trace.js +440 -440
  107. package/src/backend/lib/native-task-transcript.js +393 -393
  108. package/src/backend/lib/prompt-registry.js +166 -166
  109. package/src/backend/lib/runtime-config.js +121 -121
  110. package/src/backend/lib/sdk-session-stream.js +143 -143
  111. package/src/backend/lib/sdk-turn-stream.js +16 -16
  112. package/src/backend/lib/store-scope.js +131 -131
  113. package/src/backend/lib/task-identity.js +104 -104
  114. package/src/backend/lib/task-state-projection.js +58 -58
  115. package/src/index.js +7 -7
  116. package/src/sdk-wave-a.ts +1 -1
  117. package/src/sdk-wave-b.ts +1 -1
  118. package/src/sdk-wave-c.ts +1 -1
  119. package/src/sdk-wave-d.ts +1 -1
  120. 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 };