@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.
- package/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +652 -613
- package/apps/agent-terminal/app.js +143 -67
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +172 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +189 -159
- package/apps/agent-terminal/right-workspace-dock.js +652 -521
- package/apps/agent-terminal/user-question-card.js +68 -0
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +15 -13
- package/docs/sdk/openapi.yaml +16 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1461 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/user-questions.js +67 -0
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2408 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- 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)
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
error
|
|
58
|
-
error.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (!
|
|
91
|
-
if (!isCleanString(scope.
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (parsed
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
if (!
|
|
156
|
-
if (!
|
|
157
|
-
let
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
if (parsed.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
// a
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
written
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
f.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
try {
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
+
};
|