@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,526 +1,526 @@
1
- 'use strict';
2
-
3
- // Native task history reader - serves native background-task facts for one
4
- // session out of that session's durable history file. The { file, scope }
5
- // pair is server-trusted input: the file path never comes from a cursor or
6
- // a client. The reader opens the file read-only, streams it in bounded
7
- // chunks (the full log text never resides in memory), measures real byte
8
- // offsets - multi-byte UTF-8 and CRLF included - and feeds every accepted
9
- // row to the reviewed NativeTaskHistoryIndex, which owns all identity and
10
- // attribution decisions. Public cursor tokens are opaque base64url values
11
- // binding version, file era (fileId), full scope, full native task ref,
12
- // the exclusive before-offset and an anchor row hash; they never carry a
13
- // path. Era/truncation/anchor divergence is always an explicit 409 resync
14
- // signal, never a silent empty page. Legacy files without a fileId stay
15
- // untouched: task history is explicitly unavailable for them.
16
-
17
- const crypto = require('node:crypto');
18
- const fsp = require('node:fs/promises');
19
- const path = require('node:path');
20
- const { NativeTaskHistoryIndex } = require('./native-task-history-index');
21
- const { createTaskScope, createNativeTaskRef, nativeTaskKey, sameTaskScope } = require('./task-identity');
22
-
23
- const HISTORY_SCHEMA = 'workbench-history/v1';
24
- const CURSOR_VERSION = 1;
25
- const CURSOR_TOKEN_RE = /^[A-Za-z0-9_-]+$/;
26
- const CURSOR_HASH_RE = /^[0-9a-f]{64}$/;
27
- const CURSOR_KEYS = ['a', 'b', 'f', 's', 't', 'v'];
28
- const SCOPE_KEYS = ['contextId', 'parentSessionId', 'storeScope'];
29
- const REF_KEYS = ['contextId', 'kind', 'ownerRunId', 'parentSessionId', 'storeScope', 'taskId'];
30
- const MAX_CURSOR_CHARS = 4096;
31
- const MAX_PATH_CHARS = 4096;
32
- const MAX_ID_CHARS = 512;
33
- const DEFAULT_LIMIT = 100;
34
- const MAX_LIMIT = 1000;
35
- const SCAN_CHUNK_BYTES = 1024 * 1024;
36
- const MAX_ROW_BYTES = 16 * 1024 * 1024;
37
- const MAX_PAGE_BYTES = 4 * 1024 * 1024;
38
- const CACHE_MAX_READERS = 8;
39
- const HEADER_PROBE_BYTES = 64 * 1024;
40
- const READER_DIAGNOSTIC_CAP = 100;
41
- const RESPONSE_DIAGNOSTIC_CAP = 20;
42
-
43
- const readerCache = new Map();
44
- let cacheClock = 0;
45
-
46
- function fail(code, status, message) { throw Object.assign(new Error(message), { code, status }); }
47
-
48
- function isId(value, max = MAX_ID_CHARS) { return typeof value === 'string' && value !== '' && value.length <= max; }
49
-
50
- function rowContentHash(event) { return crypto.createHash('sha256').update(JSON.stringify(event)).digest('hex'); }
51
-
52
- function encodeCursor(payload) { return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); }
53
-
54
- function decodeCursor(token) {
55
- if (typeof token !== 'string' || token === '' || token.length > MAX_CURSOR_CHARS || !CURSOR_TOKEN_RE.test(token)) {
56
- fail('INVALID_CURSOR', 400, 'cursor must be an opaque base64url token');
57
- }
58
- let parsed = null;
59
- try { parsed = JSON.parse(Buffer.from(token, 'base64url').toString('utf8')); } catch (error) { parsed = null; }
60
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) fail('INVALID_CURSOR', 400, 'cursor payload must be a JSON object');
61
- if (JSON.stringify(Object.keys(parsed).sort()) !== JSON.stringify(CURSOR_KEYS)) fail('INVALID_CURSOR', 400, 'cursor payload fields are invalid');
62
- if (parsed.v !== CURSOR_VERSION) fail('INVALID_CURSOR', 400, 'cursor version is not supported');
63
- if (!isId(parsed.f)) fail('INVALID_CURSOR', 400, 'cursor file era is invalid');
64
- if (!parsed.s || typeof parsed.s !== 'object' || Array.isArray(parsed.s)
65
- || JSON.stringify(Object.keys(parsed.s).sort()) !== JSON.stringify(SCOPE_KEYS)) fail('INVALID_CURSOR', 400, 'cursor scope is invalid');
66
- for (const key of SCOPE_KEYS) if (!isId(parsed.s[key])) fail('INVALID_CURSOR', 400, 'cursor scope is invalid');
67
- if (!parsed.t || typeof parsed.t !== 'object' || Array.isArray(parsed.t) || parsed.t.kind !== 'native-task'
68
- || JSON.stringify(Object.keys(parsed.t).sort()) !== JSON.stringify(REF_KEYS)) fail('INVALID_CURSOR', 400, 'cursor task reference is invalid');
69
- for (const key of REF_KEYS) if (!isId(parsed.t[key])) fail('INVALID_CURSOR', 400, 'cursor task reference is invalid');
70
- if (createNativeTaskRef(parsed.t) === null) fail('INVALID_CURSOR', 400, 'cursor task reference is invalid');
71
- if (!Number.isSafeInteger(parsed.b) || parsed.b <= 0) fail('INVALID_CURSOR', 400, 'cursor offset is invalid');
72
- if (!parsed.a || typeof parsed.a !== 'object' || Array.isArray(parsed.a) || Object.keys(parsed.a).length !== 2) fail('INVALID_CURSOR', 400, 'cursor anchor is invalid');
73
- if (!Number.isSafeInteger(parsed.a.o) || parsed.a.o <= 0 || parsed.a.o !== parsed.b
74
- || typeof parsed.a.h !== 'string' || !CURSOR_HASH_RE.test(parsed.a.h)) fail('INVALID_CURSOR', 400, 'cursor anchor is invalid');
75
- return parsed;
76
- }
77
-
78
- class NativeTaskHistoryReader {
79
- constructor({ file, scope } = {}) {
80
- if (!isId(file, MAX_PATH_CHARS) || !path.isAbsolute(file)) fail('INVALID_READER_ARGS', 400, 'reader requires an absolute history file path');
81
- let resolved = null;
82
- try { resolved = path.resolve(file); } catch (error) { resolved = null; }
83
- if (!resolved || !path.isAbsolute(resolved)) fail('INVALID_READER_ARGS', 400, 'history file path must be absolute');
84
- const taskScope = createTaskScope(scope);
85
- if (!taskScope) fail('INVALID_READER_ARGS', 400, 'reader requires a valid fixed TaskScope');
86
- this._file = resolved;
87
- this._scope = Object.freeze({ storeScope: taskScope.storeScope, contextId: taskScope.contextId, parentSessionId: taskScope.parentSessionId });
88
- }
89
-
90
- get file() { return this._file; }
91
- get scope() { return { ...this._scope }; }
92
-
93
- async listTasks() {
94
- const state = await this._refresh();
95
- this._requireEra(state);
96
- return {
97
- fileId: state.fileId,
98
- tasks: state.index.listTasks(),
99
- incomplete: [...state.incomplete],
100
- diagnostics: this._diagnosticView(state),
101
- };
102
- }
103
-
104
- async readTaskEvents(taskRef, options = {}) {
105
- if (!taskRef || typeof taskRef !== 'object' || Array.isArray(taskRef)) fail('INVALID_TASK_REF', 400, 'taskRef must be a native-task reference object');
106
- if (!options || typeof options !== 'object' || Array.isArray(options)) fail('INVALID_CURSOR', 400, 'options must be an object');
107
- const limit = options.limit === undefined ? DEFAULT_LIMIT : options.limit;
108
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIMIT) fail('INVALID_LIMIT', 400, 'limit must be an integer between 1 and ' + MAX_LIMIT);
109
- const ref = createNativeTaskRef(taskRef);
110
- if (!ref) fail('INVALID_TASK_REF', 400, 'taskRef is not a valid native-task reference');
111
- if (!sameTaskScope(ref, this._scope)) fail('SCOPE_MISMATCH', 409, 'taskRef scope does not match this reader');
112
- const cursor = options.cursor === undefined || options.cursor === null ? null : decodeCursor(options.cursor);
113
- const state = await this._refresh();
114
- this._requireEra(state);
115
- if (cursor) {
116
- if (cursor.f !== state.fileId) fail('FILE_ERA_CHANGED', 409, 'history file era changed; resync from a fresh page');
117
- if (cursor.s.storeScope !== this._scope.storeScope || cursor.s.contextId !== this._scope.contextId
118
- || cursor.s.parentSessionId !== this._scope.parentSessionId) fail('SCOPE_MISMATCH', 409, 'cursor scope does not match this reader');
119
- if (nativeTaskKey(createNativeTaskRef(cursor.t)) !== nativeTaskKey(ref)) fail('SCOPE_MISMATCH', 409, 'cursor belongs to a different task');
120
- }
121
- const refKey = nativeTaskKey(ref);
122
- const bound = state.index.listTasks().some(task => task.taskRef && nativeTaskKey(task.taskRef) === refKey);
123
- if (!bound) fail('TASK_NOT_INDEXED', 404, 'task is not present in this history');
124
- let handle = null;
125
- try {
126
- handle = await fsp.open(this._file, 'r');
127
- await this._verifyReadHandle(handle, state);
128
- if (cursor) await this._verifyAnchor(handle, state, ref, cursor);
129
- const page = state.index.listOffsets(ref, { before: cursor ? cursor.b : undefined, limit });
130
- // Byte-cap selection keeps the NEWEST complete suffix of the window.
131
- // Rows dropped by the cap are older than every returned row, stay
132
- // below the next cursor bound, and are served by the following page -
133
- // across all pages every servable row appears exactly once. A row
134
- // that alone exceeds the cap surfaces as an explicit 413 the moment
135
- // it becomes the newest candidate of a window, wherever it sits.
136
- const selected = [];
137
- let pageBytes = 0;
138
- let capped = false;
139
- for (let i = page.entries.length - 1; i >= 0; i--) {
140
- const entry = page.entries[i];
141
- const rowBytes = entry.endOffset - entry.offset;
142
- if (selected.length === 0 && rowBytes > MAX_PAGE_BYTES) fail('ROW_TOO_LARGE', 413, 'a single history row exceeds the page byte cap');
143
- if (pageBytes + rowBytes > MAX_PAGE_BYTES) { capped = true; break; }
144
- selected.unshift(entry);
145
- pageBytes += rowBytes;
146
- }
147
- const events = selected.length > 0 ? await this._readRows(handle, selected) : [];
148
- const hasMore = capped || page.nextBefore !== null;
149
- const nextCursor = hasMore && selected.length > 0
150
- ? encodeCursor({ v: CURSOR_VERSION, f: state.fileId, s: { ...this._scope }, t: { ...ref }, b: selected[0].offset, a: { o: selected[0].offset, h: selected[0].hash } })
151
- : null;
152
- return {
153
- events,
154
- nextCursor,
155
- hasMore,
156
- fileId: state.fileId,
157
- incomplete: [...state.incomplete],
158
- diagnostics: this._diagnosticView(state),
159
- };
160
- } catch (error) {
161
- if (error && typeof error.status === 'number') throw error;
162
- fail('HISTORY_SCAN_FAULT', 500, 'history page read failed');
163
- } finally {
164
- if (handle) await handle.close().catch(() => {});
165
- }
166
- }
167
-
168
- _requireEra(state) {
169
- if (state.fileId === null) fail('TASK_HISTORY_LEGACY', 409, 'task history is unavailable for this legacy-era history file; no rewrite or migration is performed');
170
- }
171
-
172
- async _verifyAnchor(handle, state, ref, cursor) {
173
- const probeBound = cursor.a.o < Number.MAX_SAFE_INTEGER ? cursor.a.o + 1 : cursor.a.o;
174
- const probe = state.index.listOffsets(ref, { before: probeBound, limit: 1 });
175
- const hit = probe.entries.length > 0 ? probe.entries[0] : null;
176
- if (!hit || hit.offset !== cursor.a.o) {
177
- if (cursor.a.o >= state.fileSize) fail('HISTORY_TRUNCATED', 409, 'history file was truncated; resync from a fresh page');
178
- fail('ANCHOR_MISMATCH', 409, 'cursor anchor row is gone; resync from a fresh page');
179
- }
180
- if (hit.hash !== cursor.a.h) fail('ANCHOR_MISMATCH', 409, 'cursor anchor row changed; resync from a fresh page');
181
- // The index hash can be stale after a same-size in-place edit (old rows
182
- // are never rescanned by incremental scans), so the anchor row is
183
- // re-read from disk and re-hashed before any page is served on top of
184
- // it - the rows about to be returned are not enough to prove the
185
- // anchor still exists as the cursor saw it.
186
- const bytes = await this._readExact(handle, hit.offset, hit.endOffset);
187
- if (bytes === null) fail('HISTORY_TRUNCATED', 409, 'history row is no longer fully present; resync from a fresh page');
188
- let parsed = null;
189
- try { parsed = JSON.parse(bytes.toString('utf8')); } catch (error) { parsed = null; }
190
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || rowContentHash(parsed) !== cursor.a.h) {
191
- fail('ANCHOR_MISMATCH', 409, 'cursor anchor row changed on disk; resync from a fresh page');
192
- }
193
- }
194
-
195
- // The page-read handle must be the same file the index was built from:
196
- // a replacement between the scan and this open would otherwise serve a
197
- // new file's rows under the old era. Any divergence drops the cached
198
- // state so the next call rebuilds.
199
- async _verifyReadHandle(handle, state) {
200
- const stats = await handle.stat();
201
- if (stats.dev !== state.dev || stats.ino !== state.ino) {
202
- this._invalidateState(state);
203
- fail('FILE_ERA_CHANGED', 409, 'history file identity changed under the reader; resync from a fresh page');
204
- }
205
- const line = await this._readHeaderLine(handle);
206
- let header = null;
207
- if (line !== null) {
208
- let end = line.length;
209
- if (end > 0 && line[end - 1] === 0x0A) end--;
210
- if (end > 0 && line[end - 1] === 0x0D) end--;
211
- try { header = JSON.parse(line.toString('utf8', 0, end)); } catch (error) { header = null; }
212
- }
213
- if (!header || typeof header !== 'object' || header.schema !== HISTORY_SCHEMA
214
- || header.sessionId !== this._scope.parentSessionId || header.contextId !== this._scope.contextId
215
- || !isId(header.fileId) || header.fileId !== state.fileId) {
216
- this._invalidateState(state);
217
- fail('FILE_ERA_CHANGED', 409, 'history file era diverged under the reader; resync from a fresh page');
218
- }
219
- }
220
-
221
- _invalidateState(state) {
222
- for (const entry of readerCache.values()) {
223
- if (entry.state === state) entry.state = null;
224
- }
225
- }
226
-
227
- async _readExact(handle, offset, endOffset) {
228
- const length = endOffset - offset;
229
- const buffer = Buffer.alloc(length);
230
- let done = 0;
231
- while (done < length) {
232
- const { bytesRead } = await handle.read(buffer, done, length - done, offset + done);
233
- if (bytesRead <= 0) return null;
234
- done += bytesRead;
235
- }
236
- return buffer;
237
- }
238
-
239
- async _readRows(handle, entries) {
240
- const events = new Array(entries.length);
241
- for (let i = 0; i < entries.length; i++) {
242
- const entry = entries[i];
243
- const bytes = await this._readExact(handle, entry.offset, entry.endOffset);
244
- if (bytes === null) fail('HISTORY_TRUNCATED', 409, 'history row is no longer fully present; resync from a fresh page');
245
- let event = null;
246
- try { event = JSON.parse(bytes.toString('utf8')); } catch (error) { event = null; }
247
- if (!event || typeof event !== 'object' || Array.isArray(event)) fail('HISTORY_MUTATED', 409, 'history row no longer parses; resync from a fresh page');
248
- if (rowContentHash(event) !== entry.hash) fail('HISTORY_MUTATED', 409, 'history row content diverged from the index; resync from a fresh page');
249
- events[i] = event;
250
- }
251
- return events;
252
- }
253
-
254
- async _refresh() {
255
- const cacheKey = JSON.stringify([this._file, this._scope.storeScope, this._scope.contextId, this._scope.parentSessionId]);
256
- let entry = readerCache.get(cacheKey);
257
- if (!entry) {
258
- entry = { lastUsed: 0, state: null, chain: null };
259
- readerCache.set(cacheKey, entry);
260
- }
261
- entry.lastUsed = ++cacheClock;
262
- while (readerCache.size > CACHE_MAX_READERS) {
263
- let oldestKey = null;
264
- let oldest = Infinity;
265
- for (const [key, value] of readerCache) if (value !== entry && value.lastUsed < oldest) { oldest = value.lastUsed; oldestKey = key; }
266
- if (oldestKey === null) break;
267
- readerCache.delete(oldestKey);
268
- }
269
- // One scan at a time per (file, scope): concurrent callers queue behind
270
- // the in-flight refresh instead of racing incremental scans over the
271
- // shared mutable index state.
272
- const run = async () => {
273
- let stats = null;
274
- try { stats = await fsp.lstat(this._file); }
275
- catch (error) {
276
- if (error && error.code === 'ENOENT') fail('HISTORY_NOT_FOUND', 404, 'history file does not exist');
277
- fail('HISTORY_OPEN_FAILED', 500, 'history file cannot be examined');
278
- }
279
- if (stats.isSymbolicLink() || !stats.isFile()) fail('HISTORY_NOT_REGULAR', 400, 'history file must be a regular file, not a symlink or device');
280
- let state = entry.state;
281
- const replaced = state !== null && (state.dev !== stats.dev || state.ino !== stats.ino);
282
- const truncated = state !== null && !replaced && stats.size < state.indexedBytes;
283
- if (state === null || replaced || truncated) {
284
- const priorBytes = state === null ? 0 : state.indexedBytes;
285
- state = {
286
- file: this._file,
287
- scope: { ...this._scope },
288
- index: new NativeTaskHistoryIndex({ ...this._scope }),
289
- fileId: null,
290
- fileSize: stats.size,
291
- mtimeMs: stats.mtimeMs,
292
- dev: stats.dev,
293
- ino: stats.ino,
294
- indexedBytes: 0,
295
- incomplete: new Set(),
296
- diagnostics: [],
297
- scanned: false,
298
- };
299
- entry.state = state;
300
- if (truncated) this._diag(state, 'history-truncated-rebuilt', { previousIndexedBytes: priorBytes });
301
- }
302
- const unchanged = state.scanned && stats.size === state.indexedBytes && stats.mtimeMs === state.mtimeMs;
303
- if (!unchanged) await this._scan(state);
304
- return state;
305
- };
306
- const prior = entry.chain || Promise.resolve();
307
- const next = prior.then(run, run);
308
- entry.chain = next.then(() => {}, () => {});
309
- return await next;
310
- }
311
-
312
- async _scan(state) {
313
- let handle = null;
314
- try {
315
- handle = await fsp.open(this._file, 'r');
316
- const opened = await handle.stat();
317
- if (state.indexedBytes > 0) {
318
- const priorBytes = state.indexedBytes;
319
- let rebuild = opened.size < priorBytes;
320
- let reason = 'history-truncated-rebuilt';
321
- if (!rebuild) {
322
- const header = await this._readHeaderLine(handle);
323
- if (header === null) {
324
- rebuild = true;
325
- reason = 'history-replaced-rebuilt';
326
- } else {
327
- const priorFileId = state.fileId;
328
- this._consumeHeader(state, header);
329
- if (state.fileId !== priorFileId) {
330
- rebuild = true;
331
- reason = 'history-replaced-rebuilt';
332
- }
333
- }
334
- }
335
- if (rebuild) {
336
- state.index = new NativeTaskHistoryIndex({ ...this._scope });
337
- state.fileId = null;
338
- state.indexedBytes = 0;
339
- state.incomplete = new Set();
340
- state.diagnostics = [];
341
- this._diag(state, reason, { previousIndexedBytes: priorBytes });
342
- }
343
- }
344
- state.dev = opened.dev;
345
- state.ino = opened.ino;
346
- await this._scanFrom(state, handle);
347
- const done = await handle.stat();
348
- state.fileSize = done.size;
349
- state.mtimeMs = done.mtimeMs;
350
- state.dev = done.dev;
351
- state.ino = done.ino;
352
- state.scanned = true;
353
- } catch (error) {
354
- state.scanned = false;
355
- if (error && typeof error.status === 'number') throw error;
356
- fail('HISTORY_SCAN_FAULT', 500, 'history file scan failed');
357
- } finally {
358
- if (handle) await handle.close().catch(() => {});
359
- }
360
- }
361
-
362
- async _readHeaderLine(handle) {
363
- const probe = Buffer.alloc(HEADER_PROBE_BYTES);
364
- const { bytesRead } = await handle.read(probe, 0, probe.length, 0);
365
- const nl = probe.subarray(0, bytesRead).indexOf(0x0A);
366
- if (nl === -1) return null;
367
- return probe.subarray(0, nl + 1);
368
- }
369
-
370
- async _scanFrom(state, handle) {
371
- const chunk = Buffer.alloc(SCAN_CHUNK_BYTES);
372
- let position = state.indexedBytes;
373
- let carry = null;
374
- let carryStart = position;
375
- let carryDropped = false;
376
- for (;;) {
377
- const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
378
- if (bytesRead <= 0) break;
379
- let cursor = 0;
380
- for (;;) {
381
- const nl = chunk.indexOf(0x0A, cursor);
382
- if (nl === -1 || nl >= bytesRead) break;
383
- const endOffset = position + nl + 1;
384
- const offset = carry !== null ? carryStart : position + cursor;
385
- let lineBuffer = null;
386
- if (carry !== null && !carryDropped) {
387
- const tail = chunk.subarray(cursor, nl + 1);
388
- lineBuffer = Buffer.concat([carry, tail], carry.length + tail.length);
389
- } else if (carry === null) {
390
- lineBuffer = chunk.subarray(cursor, nl + 1);
391
- }
392
- if (lineBuffer !== null && lineBuffer.length <= MAX_ROW_BYTES) {
393
- this._consumeLine(state, offset, endOffset, lineBuffer);
394
- } else {
395
- if (offset === 0) fail('HISTORY_HEADER_INVALID', 400, 'history file does not begin with a valid workbench-history/v1 header');
396
- this._diag(state, 'scan-oversized-row', { offset, bytes: lineBuffer === null ? null : lineBuffer.length });
397
- state.incomplete.add('oversized-row');
398
- }
399
- carry = null;
400
- carryDropped = false;
401
- cursor = nl + 1;
402
- }
403
- if (cursor < bytesRead) {
404
- const rest = Buffer.from(chunk.subarray(cursor, bytesRead));
405
- if (carry !== null) {
406
- if (carryDropped || carry.length + rest.length > MAX_ROW_BYTES) {
407
- if (!carryDropped) {
408
- this._diag(state, 'scan-oversized-row', { offset: carryStart, bytes: carry.length + rest.length });
409
- state.incomplete.add('oversized-row');
410
- }
411
- carryDropped = true;
412
- carry = null;
413
- } else {
414
- carry = Buffer.concat([carry, rest], carry.length + rest.length);
415
- }
416
- } else if (rest.length > MAX_ROW_BYTES) {
417
- this._diag(state, 'scan-oversized-row', { offset: carryStart, bytes: rest.length });
418
- state.incomplete.add('oversized-row');
419
- carryDropped = true;
420
- carry = null;
421
- } else {
422
- carry = rest;
423
- carryStart = position + cursor;
424
- }
425
- }
426
- position += bytesRead;
427
- }
428
- if (carry !== null || carryDropped) {
429
- if (carry !== null) state.incomplete.add('unfinished-tail');
430
- state.indexedBytes = carryStart;
431
- } else {
432
- state.indexedBytes = position;
433
- }
434
- }
435
-
436
- _consumeLine(state, offset, endOffset, lineBuffer) {
437
- if (offset === 0) {
438
- this._consumeHeader(state, lineBuffer);
439
- return;
440
- }
441
- const event = this._parseLine(state, offset, lineBuffer);
442
- if (event === null) return;
443
- if (event.type === 'workbench/history-checkpoint') return;
444
- if (event.type === 'workbench/history-gap') {
445
- state.incomplete.add('history-gap');
446
- return;
447
- }
448
- state.index.observe({ offset, endOffset, event });
449
- }
450
-
451
- _consumeHeader(state, lineBuffer) {
452
- const header = this._parseLine(state, 0, lineBuffer);
453
- if (header === null || header.schema !== HISTORY_SCHEMA || !isId(header.sessionId) || !isId(header.contextId)) {
454
- fail('HISTORY_HEADER_INVALID', 400, 'history file does not begin with a valid workbench-history/v1 header');
455
- }
456
- if (header.sessionId !== this._scope.parentSessionId || header.contextId !== this._scope.contextId) {
457
- fail('HISTORY_OWNERSHIP_MISMATCH', 409, 'history file belongs to a different session scope');
458
- }
459
- state.fileId = isId(header.fileId) ? header.fileId : null;
460
- if (header.incomplete === true) state.incomplete.add('header-incomplete');
461
- }
462
-
463
- _parseLine(state, offset, lineBuffer) {
464
- let end = lineBuffer.length;
465
- if (end > 0 && lineBuffer[end - 1] === 0x0A) end--;
466
- if (end > 0 && lineBuffer[end - 1] === 0x0D) end--;
467
- let parsed = null;
468
- try { parsed = JSON.parse(lineBuffer.toString('utf8', 0, end)); } catch (error) { parsed = null; }
469
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
470
- this._diag(state, 'scan-corrupt-row', { offset });
471
- state.incomplete.add('corrupt-row');
472
- return null;
473
- }
474
- return parsed;
475
- }
476
-
477
- _diag(state, code, ids) {
478
- if (state.diagnostics.length >= READER_DIAGNOSTIC_CAP) return;
479
- state.diagnostics.push(Object.assign({ code }, ids || {}));
480
- }
481
-
482
- _diagnosticView(state) {
483
- const own = state.diagnostics.map(item => Object.assign({}, item, { source: 'reader' }));
484
- const indexed = state.index.diagnostics().map(item => Object.assign({}, item, { source: 'index' }));
485
- const all = own.concat(indexed);
486
- return {
487
- items: all.slice(0, RESPONSE_DIAGNOSTIC_CAP),
488
- total: all.length,
489
- truncated: all.length > RESPONSE_DIAGNOSTIC_CAP,
490
- };
491
- }
492
- }
493
-
494
- function nativeTaskHistoryCacheStats() {
495
- const readers = [];
496
- for (const entry of readerCache.values()) {
497
- const state = entry.state;
498
- if (!state) continue;
499
- readers.push({
500
- file: state.file,
501
- scope: state.scope,
502
- fileId: state.fileId,
503
- fileSize: state.fileSize,
504
- indexedBytes: state.indexedBytes,
505
- incomplete: [...state.incomplete],
506
- index: state.index.stats(),
507
- });
508
- }
509
- return { entries: readerCache.size, maxEntries: CACHE_MAX_READERS, readers };
510
- }
511
-
512
- function createNativeTaskHistoryReader(options) { return new NativeTaskHistoryReader(options); }
513
-
514
- module.exports = {
515
- NativeTaskHistoryReader,
516
- createNativeTaskHistoryReader,
517
- nativeTaskHistoryCacheStats,
518
- NATIVE_TASK_HISTORY_READER_LIMITS: Object.freeze({
519
- defaultLimit: DEFAULT_LIMIT,
520
- maxLimit: MAX_LIMIT,
521
- maxPageBytes: MAX_PAGE_BYTES,
522
- maxRowBytes: MAX_ROW_BYTES,
523
- scanChunkBytes: SCAN_CHUNK_BYTES,
524
- maxCursorChars: MAX_CURSOR_CHARS,
525
- }),
526
- };
1
+ 'use strict';
2
+
3
+ // Native task history reader - serves native background-task facts for one
4
+ // session out of that session's durable history file. The { file, scope }
5
+ // pair is server-trusted input: the file path never comes from a cursor or
6
+ // a client. The reader opens the file read-only, streams it in bounded
7
+ // chunks (the full log text never resides in memory), measures real byte
8
+ // offsets - multi-byte UTF-8 and CRLF included - and feeds every accepted
9
+ // row to the reviewed NativeTaskHistoryIndex, which owns all identity and
10
+ // attribution decisions. Public cursor tokens are opaque base64url values
11
+ // binding version, file era (fileId), full scope, full native task ref,
12
+ // the exclusive before-offset and an anchor row hash; they never carry a
13
+ // path. Era/truncation/anchor divergence is always an explicit 409 resync
14
+ // signal, never a silent empty page. Legacy files without a fileId stay
15
+ // untouched: task history is explicitly unavailable for them.
16
+
17
+ const crypto = require('node:crypto');
18
+ const fsp = require('node:fs/promises');
19
+ const path = require('node:path');
20
+ const { NativeTaskHistoryIndex } = require('./native-task-history-index');
21
+ const { createTaskScope, createNativeTaskRef, nativeTaskKey, sameTaskScope } = require('./task-identity');
22
+
23
+ const HISTORY_SCHEMA = 'workbench-history/v1';
24
+ const CURSOR_VERSION = 1;
25
+ const CURSOR_TOKEN_RE = /^[A-Za-z0-9_-]+$/;
26
+ const CURSOR_HASH_RE = /^[0-9a-f]{64}$/;
27
+ const CURSOR_KEYS = ['a', 'b', 'f', 's', 't', 'v'];
28
+ const SCOPE_KEYS = ['contextId', 'parentSessionId', 'storeScope'];
29
+ const REF_KEYS = ['contextId', 'kind', 'ownerRunId', 'parentSessionId', 'storeScope', 'taskId'];
30
+ const MAX_CURSOR_CHARS = 4096;
31
+ const MAX_PATH_CHARS = 4096;
32
+ const MAX_ID_CHARS = 512;
33
+ const DEFAULT_LIMIT = 100;
34
+ const MAX_LIMIT = 1000;
35
+ const SCAN_CHUNK_BYTES = 1024 * 1024;
36
+ const MAX_ROW_BYTES = 16 * 1024 * 1024;
37
+ const MAX_PAGE_BYTES = 4 * 1024 * 1024;
38
+ const CACHE_MAX_READERS = 8;
39
+ const HEADER_PROBE_BYTES = 64 * 1024;
40
+ const READER_DIAGNOSTIC_CAP = 100;
41
+ const RESPONSE_DIAGNOSTIC_CAP = 20;
42
+
43
+ const readerCache = new Map();
44
+ let cacheClock = 0;
45
+
46
+ function fail(code, status, message) { throw Object.assign(new Error(message), { code, status }); }
47
+
48
+ function isId(value, max = MAX_ID_CHARS) { return typeof value === 'string' && value !== '' && value.length <= max; }
49
+
50
+ function rowContentHash(event) { return crypto.createHash('sha256').update(JSON.stringify(event)).digest('hex'); }
51
+
52
+ function encodeCursor(payload) { return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); }
53
+
54
+ function decodeCursor(token) {
55
+ if (typeof token !== 'string' || token === '' || token.length > MAX_CURSOR_CHARS || !CURSOR_TOKEN_RE.test(token)) {
56
+ fail('INVALID_CURSOR', 400, 'cursor must be an opaque base64url token');
57
+ }
58
+ let parsed = null;
59
+ try { parsed = JSON.parse(Buffer.from(token, 'base64url').toString('utf8')); } catch (error) { parsed = null; }
60
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) fail('INVALID_CURSOR', 400, 'cursor payload must be a JSON object');
61
+ if (JSON.stringify(Object.keys(parsed).sort()) !== JSON.stringify(CURSOR_KEYS)) fail('INVALID_CURSOR', 400, 'cursor payload fields are invalid');
62
+ if (parsed.v !== CURSOR_VERSION) fail('INVALID_CURSOR', 400, 'cursor version is not supported');
63
+ if (!isId(parsed.f)) fail('INVALID_CURSOR', 400, 'cursor file era is invalid');
64
+ if (!parsed.s || typeof parsed.s !== 'object' || Array.isArray(parsed.s)
65
+ || JSON.stringify(Object.keys(parsed.s).sort()) !== JSON.stringify(SCOPE_KEYS)) fail('INVALID_CURSOR', 400, 'cursor scope is invalid');
66
+ for (const key of SCOPE_KEYS) if (!isId(parsed.s[key])) fail('INVALID_CURSOR', 400, 'cursor scope is invalid');
67
+ if (!parsed.t || typeof parsed.t !== 'object' || Array.isArray(parsed.t) || parsed.t.kind !== 'native-task'
68
+ || JSON.stringify(Object.keys(parsed.t).sort()) !== JSON.stringify(REF_KEYS)) fail('INVALID_CURSOR', 400, 'cursor task reference is invalid');
69
+ for (const key of REF_KEYS) if (!isId(parsed.t[key])) fail('INVALID_CURSOR', 400, 'cursor task reference is invalid');
70
+ if (createNativeTaskRef(parsed.t) === null) fail('INVALID_CURSOR', 400, 'cursor task reference is invalid');
71
+ if (!Number.isSafeInteger(parsed.b) || parsed.b <= 0) fail('INVALID_CURSOR', 400, 'cursor offset is invalid');
72
+ if (!parsed.a || typeof parsed.a !== 'object' || Array.isArray(parsed.a) || Object.keys(parsed.a).length !== 2) fail('INVALID_CURSOR', 400, 'cursor anchor is invalid');
73
+ if (!Number.isSafeInteger(parsed.a.o) || parsed.a.o <= 0 || parsed.a.o !== parsed.b
74
+ || typeof parsed.a.h !== 'string' || !CURSOR_HASH_RE.test(parsed.a.h)) fail('INVALID_CURSOR', 400, 'cursor anchor is invalid');
75
+ return parsed;
76
+ }
77
+
78
+ class NativeTaskHistoryReader {
79
+ constructor({ file, scope } = {}) {
80
+ if (!isId(file, MAX_PATH_CHARS) || !path.isAbsolute(file)) fail('INVALID_READER_ARGS', 400, 'reader requires an absolute history file path');
81
+ let resolved = null;
82
+ try { resolved = path.resolve(file); } catch (error) { resolved = null; }
83
+ if (!resolved || !path.isAbsolute(resolved)) fail('INVALID_READER_ARGS', 400, 'history file path must be absolute');
84
+ const taskScope = createTaskScope(scope);
85
+ if (!taskScope) fail('INVALID_READER_ARGS', 400, 'reader requires a valid fixed TaskScope');
86
+ this._file = resolved;
87
+ this._scope = Object.freeze({ storeScope: taskScope.storeScope, contextId: taskScope.contextId, parentSessionId: taskScope.parentSessionId });
88
+ }
89
+
90
+ get file() { return this._file; }
91
+ get scope() { return { ...this._scope }; }
92
+
93
+ async listTasks() {
94
+ const state = await this._refresh();
95
+ this._requireEra(state);
96
+ return {
97
+ fileId: state.fileId,
98
+ tasks: state.index.listTasks(),
99
+ incomplete: [...state.incomplete],
100
+ diagnostics: this._diagnosticView(state),
101
+ };
102
+ }
103
+
104
+ async readTaskEvents(taskRef, options = {}) {
105
+ if (!taskRef || typeof taskRef !== 'object' || Array.isArray(taskRef)) fail('INVALID_TASK_REF', 400, 'taskRef must be a native-task reference object');
106
+ if (!options || typeof options !== 'object' || Array.isArray(options)) fail('INVALID_CURSOR', 400, 'options must be an object');
107
+ const limit = options.limit === undefined ? DEFAULT_LIMIT : options.limit;
108
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIMIT) fail('INVALID_LIMIT', 400, 'limit must be an integer between 1 and ' + MAX_LIMIT);
109
+ const ref = createNativeTaskRef(taskRef);
110
+ if (!ref) fail('INVALID_TASK_REF', 400, 'taskRef is not a valid native-task reference');
111
+ if (!sameTaskScope(ref, this._scope)) fail('SCOPE_MISMATCH', 409, 'taskRef scope does not match this reader');
112
+ const cursor = options.cursor === undefined || options.cursor === null ? null : decodeCursor(options.cursor);
113
+ const state = await this._refresh();
114
+ this._requireEra(state);
115
+ if (cursor) {
116
+ if (cursor.f !== state.fileId) fail('FILE_ERA_CHANGED', 409, 'history file era changed; resync from a fresh page');
117
+ if (cursor.s.storeScope !== this._scope.storeScope || cursor.s.contextId !== this._scope.contextId
118
+ || cursor.s.parentSessionId !== this._scope.parentSessionId) fail('SCOPE_MISMATCH', 409, 'cursor scope does not match this reader');
119
+ if (nativeTaskKey(createNativeTaskRef(cursor.t)) !== nativeTaskKey(ref)) fail('SCOPE_MISMATCH', 409, 'cursor belongs to a different task');
120
+ }
121
+ const refKey = nativeTaskKey(ref);
122
+ const bound = state.index.listTasks().some(task => task.taskRef && nativeTaskKey(task.taskRef) === refKey);
123
+ if (!bound) fail('TASK_NOT_INDEXED', 404, 'task is not present in this history');
124
+ let handle = null;
125
+ try {
126
+ handle = await fsp.open(this._file, 'r');
127
+ await this._verifyReadHandle(handle, state);
128
+ if (cursor) await this._verifyAnchor(handle, state, ref, cursor);
129
+ const page = state.index.listOffsets(ref, { before: cursor ? cursor.b : undefined, limit });
130
+ // Byte-cap selection keeps the NEWEST complete suffix of the window.
131
+ // Rows dropped by the cap are older than every returned row, stay
132
+ // below the next cursor bound, and are served by the following page -
133
+ // across all pages every servable row appears exactly once. A row
134
+ // that alone exceeds the cap surfaces as an explicit 413 the moment
135
+ // it becomes the newest candidate of a window, wherever it sits.
136
+ const selected = [];
137
+ let pageBytes = 0;
138
+ let capped = false;
139
+ for (let i = page.entries.length - 1; i >= 0; i--) {
140
+ const entry = page.entries[i];
141
+ const rowBytes = entry.endOffset - entry.offset;
142
+ if (selected.length === 0 && rowBytes > MAX_PAGE_BYTES) fail('ROW_TOO_LARGE', 413, 'a single history row exceeds the page byte cap');
143
+ if (pageBytes + rowBytes > MAX_PAGE_BYTES) { capped = true; break; }
144
+ selected.unshift(entry);
145
+ pageBytes += rowBytes;
146
+ }
147
+ const events = selected.length > 0 ? await this._readRows(handle, selected) : [];
148
+ const hasMore = capped || page.nextBefore !== null;
149
+ const nextCursor = hasMore && selected.length > 0
150
+ ? encodeCursor({ v: CURSOR_VERSION, f: state.fileId, s: { ...this._scope }, t: { ...ref }, b: selected[0].offset, a: { o: selected[0].offset, h: selected[0].hash } })
151
+ : null;
152
+ return {
153
+ events,
154
+ nextCursor,
155
+ hasMore,
156
+ fileId: state.fileId,
157
+ incomplete: [...state.incomplete],
158
+ diagnostics: this._diagnosticView(state),
159
+ };
160
+ } catch (error) {
161
+ if (error && typeof error.status === 'number') throw error;
162
+ fail('HISTORY_SCAN_FAULT', 500, 'history page read failed');
163
+ } finally {
164
+ if (handle) await handle.close().catch(() => {});
165
+ }
166
+ }
167
+
168
+ _requireEra(state) {
169
+ if (state.fileId === null) fail('TASK_HISTORY_LEGACY', 409, 'task history is unavailable for this legacy-era history file; no rewrite or migration is performed');
170
+ }
171
+
172
+ async _verifyAnchor(handle, state, ref, cursor) {
173
+ const probeBound = cursor.a.o < Number.MAX_SAFE_INTEGER ? cursor.a.o + 1 : cursor.a.o;
174
+ const probe = state.index.listOffsets(ref, { before: probeBound, limit: 1 });
175
+ const hit = probe.entries.length > 0 ? probe.entries[0] : null;
176
+ if (!hit || hit.offset !== cursor.a.o) {
177
+ if (cursor.a.o >= state.fileSize) fail('HISTORY_TRUNCATED', 409, 'history file was truncated; resync from a fresh page');
178
+ fail('ANCHOR_MISMATCH', 409, 'cursor anchor row is gone; resync from a fresh page');
179
+ }
180
+ if (hit.hash !== cursor.a.h) fail('ANCHOR_MISMATCH', 409, 'cursor anchor row changed; resync from a fresh page');
181
+ // The index hash can be stale after a same-size in-place edit (old rows
182
+ // are never rescanned by incremental scans), so the anchor row is
183
+ // re-read from disk and re-hashed before any page is served on top of
184
+ // it - the rows about to be returned are not enough to prove the
185
+ // anchor still exists as the cursor saw it.
186
+ const bytes = await this._readExact(handle, hit.offset, hit.endOffset);
187
+ if (bytes === null) fail('HISTORY_TRUNCATED', 409, 'history row is no longer fully present; resync from a fresh page');
188
+ let parsed = null;
189
+ try { parsed = JSON.parse(bytes.toString('utf8')); } catch (error) { parsed = null; }
190
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || rowContentHash(parsed) !== cursor.a.h) {
191
+ fail('ANCHOR_MISMATCH', 409, 'cursor anchor row changed on disk; resync from a fresh page');
192
+ }
193
+ }
194
+
195
+ // The page-read handle must be the same file the index was built from:
196
+ // a replacement between the scan and this open would otherwise serve a
197
+ // new file's rows under the old era. Any divergence drops the cached
198
+ // state so the next call rebuilds.
199
+ async _verifyReadHandle(handle, state) {
200
+ const stats = await handle.stat();
201
+ if (stats.dev !== state.dev || stats.ino !== state.ino) {
202
+ this._invalidateState(state);
203
+ fail('FILE_ERA_CHANGED', 409, 'history file identity changed under the reader; resync from a fresh page');
204
+ }
205
+ const line = await this._readHeaderLine(handle);
206
+ let header = null;
207
+ if (line !== null) {
208
+ let end = line.length;
209
+ if (end > 0 && line[end - 1] === 0x0A) end--;
210
+ if (end > 0 && line[end - 1] === 0x0D) end--;
211
+ try { header = JSON.parse(line.toString('utf8', 0, end)); } catch (error) { header = null; }
212
+ }
213
+ if (!header || typeof header !== 'object' || header.schema !== HISTORY_SCHEMA
214
+ || header.sessionId !== this._scope.parentSessionId || header.contextId !== this._scope.contextId
215
+ || !isId(header.fileId) || header.fileId !== state.fileId) {
216
+ this._invalidateState(state);
217
+ fail('FILE_ERA_CHANGED', 409, 'history file era diverged under the reader; resync from a fresh page');
218
+ }
219
+ }
220
+
221
+ _invalidateState(state) {
222
+ for (const entry of readerCache.values()) {
223
+ if (entry.state === state) entry.state = null;
224
+ }
225
+ }
226
+
227
+ async _readExact(handle, offset, endOffset) {
228
+ const length = endOffset - offset;
229
+ const buffer = Buffer.alloc(length);
230
+ let done = 0;
231
+ while (done < length) {
232
+ const { bytesRead } = await handle.read(buffer, done, length - done, offset + done);
233
+ if (bytesRead <= 0) return null;
234
+ done += bytesRead;
235
+ }
236
+ return buffer;
237
+ }
238
+
239
+ async _readRows(handle, entries) {
240
+ const events = new Array(entries.length);
241
+ for (let i = 0; i < entries.length; i++) {
242
+ const entry = entries[i];
243
+ const bytes = await this._readExact(handle, entry.offset, entry.endOffset);
244
+ if (bytes === null) fail('HISTORY_TRUNCATED', 409, 'history row is no longer fully present; resync from a fresh page');
245
+ let event = null;
246
+ try { event = JSON.parse(bytes.toString('utf8')); } catch (error) { event = null; }
247
+ if (!event || typeof event !== 'object' || Array.isArray(event)) fail('HISTORY_MUTATED', 409, 'history row no longer parses; resync from a fresh page');
248
+ if (rowContentHash(event) !== entry.hash) fail('HISTORY_MUTATED', 409, 'history row content diverged from the index; resync from a fresh page');
249
+ events[i] = event;
250
+ }
251
+ return events;
252
+ }
253
+
254
+ async _refresh() {
255
+ const cacheKey = JSON.stringify([this._file, this._scope.storeScope, this._scope.contextId, this._scope.parentSessionId]);
256
+ let entry = readerCache.get(cacheKey);
257
+ if (!entry) {
258
+ entry = { lastUsed: 0, state: null, chain: null };
259
+ readerCache.set(cacheKey, entry);
260
+ }
261
+ entry.lastUsed = ++cacheClock;
262
+ while (readerCache.size > CACHE_MAX_READERS) {
263
+ let oldestKey = null;
264
+ let oldest = Infinity;
265
+ for (const [key, value] of readerCache) if (value !== entry && value.lastUsed < oldest) { oldest = value.lastUsed; oldestKey = key; }
266
+ if (oldestKey === null) break;
267
+ readerCache.delete(oldestKey);
268
+ }
269
+ // One scan at a time per (file, scope): concurrent callers queue behind
270
+ // the in-flight refresh instead of racing incremental scans over the
271
+ // shared mutable index state.
272
+ const run = async () => {
273
+ let stats = null;
274
+ try { stats = await fsp.lstat(this._file); }
275
+ catch (error) {
276
+ if (error && error.code === 'ENOENT') fail('HISTORY_NOT_FOUND', 404, 'history file does not exist');
277
+ fail('HISTORY_OPEN_FAILED', 500, 'history file cannot be examined');
278
+ }
279
+ if (stats.isSymbolicLink() || !stats.isFile()) fail('HISTORY_NOT_REGULAR', 400, 'history file must be a regular file, not a symlink or device');
280
+ let state = entry.state;
281
+ const replaced = state !== null && (state.dev !== stats.dev || state.ino !== stats.ino);
282
+ const truncated = state !== null && !replaced && stats.size < state.indexedBytes;
283
+ if (state === null || replaced || truncated) {
284
+ const priorBytes = state === null ? 0 : state.indexedBytes;
285
+ state = {
286
+ file: this._file,
287
+ scope: { ...this._scope },
288
+ index: new NativeTaskHistoryIndex({ ...this._scope }),
289
+ fileId: null,
290
+ fileSize: stats.size,
291
+ mtimeMs: stats.mtimeMs,
292
+ dev: stats.dev,
293
+ ino: stats.ino,
294
+ indexedBytes: 0,
295
+ incomplete: new Set(),
296
+ diagnostics: [],
297
+ scanned: false,
298
+ };
299
+ entry.state = state;
300
+ if (truncated) this._diag(state, 'history-truncated-rebuilt', { previousIndexedBytes: priorBytes });
301
+ }
302
+ const unchanged = state.scanned && stats.size === state.indexedBytes && stats.mtimeMs === state.mtimeMs;
303
+ if (!unchanged) await this._scan(state);
304
+ return state;
305
+ };
306
+ const prior = entry.chain || Promise.resolve();
307
+ const next = prior.then(run, run);
308
+ entry.chain = next.then(() => {}, () => {});
309
+ return await next;
310
+ }
311
+
312
+ async _scan(state) {
313
+ let handle = null;
314
+ try {
315
+ handle = await fsp.open(this._file, 'r');
316
+ const opened = await handle.stat();
317
+ if (state.indexedBytes > 0) {
318
+ const priorBytes = state.indexedBytes;
319
+ let rebuild = opened.size < priorBytes;
320
+ let reason = 'history-truncated-rebuilt';
321
+ if (!rebuild) {
322
+ const header = await this._readHeaderLine(handle);
323
+ if (header === null) {
324
+ rebuild = true;
325
+ reason = 'history-replaced-rebuilt';
326
+ } else {
327
+ const priorFileId = state.fileId;
328
+ this._consumeHeader(state, header);
329
+ if (state.fileId !== priorFileId) {
330
+ rebuild = true;
331
+ reason = 'history-replaced-rebuilt';
332
+ }
333
+ }
334
+ }
335
+ if (rebuild) {
336
+ state.index = new NativeTaskHistoryIndex({ ...this._scope });
337
+ state.fileId = null;
338
+ state.indexedBytes = 0;
339
+ state.incomplete = new Set();
340
+ state.diagnostics = [];
341
+ this._diag(state, reason, { previousIndexedBytes: priorBytes });
342
+ }
343
+ }
344
+ state.dev = opened.dev;
345
+ state.ino = opened.ino;
346
+ await this._scanFrom(state, handle);
347
+ const done = await handle.stat();
348
+ state.fileSize = done.size;
349
+ state.mtimeMs = done.mtimeMs;
350
+ state.dev = done.dev;
351
+ state.ino = done.ino;
352
+ state.scanned = true;
353
+ } catch (error) {
354
+ state.scanned = false;
355
+ if (error && typeof error.status === 'number') throw error;
356
+ fail('HISTORY_SCAN_FAULT', 500, 'history file scan failed');
357
+ } finally {
358
+ if (handle) await handle.close().catch(() => {});
359
+ }
360
+ }
361
+
362
+ async _readHeaderLine(handle) {
363
+ const probe = Buffer.alloc(HEADER_PROBE_BYTES);
364
+ const { bytesRead } = await handle.read(probe, 0, probe.length, 0);
365
+ const nl = probe.subarray(0, bytesRead).indexOf(0x0A);
366
+ if (nl === -1) return null;
367
+ return probe.subarray(0, nl + 1);
368
+ }
369
+
370
+ async _scanFrom(state, handle) {
371
+ const chunk = Buffer.alloc(SCAN_CHUNK_BYTES);
372
+ let position = state.indexedBytes;
373
+ let carry = null;
374
+ let carryStart = position;
375
+ let carryDropped = false;
376
+ for (;;) {
377
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
378
+ if (bytesRead <= 0) break;
379
+ let cursor = 0;
380
+ for (;;) {
381
+ const nl = chunk.indexOf(0x0A, cursor);
382
+ if (nl === -1 || nl >= bytesRead) break;
383
+ const endOffset = position + nl + 1;
384
+ const offset = carry !== null ? carryStart : position + cursor;
385
+ let lineBuffer = null;
386
+ if (carry !== null && !carryDropped) {
387
+ const tail = chunk.subarray(cursor, nl + 1);
388
+ lineBuffer = Buffer.concat([carry, tail], carry.length + tail.length);
389
+ } else if (carry === null) {
390
+ lineBuffer = chunk.subarray(cursor, nl + 1);
391
+ }
392
+ if (lineBuffer !== null && lineBuffer.length <= MAX_ROW_BYTES) {
393
+ this._consumeLine(state, offset, endOffset, lineBuffer);
394
+ } else {
395
+ if (offset === 0) fail('HISTORY_HEADER_INVALID', 400, 'history file does not begin with a valid workbench-history/v1 header');
396
+ this._diag(state, 'scan-oversized-row', { offset, bytes: lineBuffer === null ? null : lineBuffer.length });
397
+ state.incomplete.add('oversized-row');
398
+ }
399
+ carry = null;
400
+ carryDropped = false;
401
+ cursor = nl + 1;
402
+ }
403
+ if (cursor < bytesRead) {
404
+ const rest = Buffer.from(chunk.subarray(cursor, bytesRead));
405
+ if (carry !== null) {
406
+ if (carryDropped || carry.length + rest.length > MAX_ROW_BYTES) {
407
+ if (!carryDropped) {
408
+ this._diag(state, 'scan-oversized-row', { offset: carryStart, bytes: carry.length + rest.length });
409
+ state.incomplete.add('oversized-row');
410
+ }
411
+ carryDropped = true;
412
+ carry = null;
413
+ } else {
414
+ carry = Buffer.concat([carry, rest], carry.length + rest.length);
415
+ }
416
+ } else if (rest.length > MAX_ROW_BYTES) {
417
+ this._diag(state, 'scan-oversized-row', { offset: carryStart, bytes: rest.length });
418
+ state.incomplete.add('oversized-row');
419
+ carryDropped = true;
420
+ carry = null;
421
+ } else {
422
+ carry = rest;
423
+ carryStart = position + cursor;
424
+ }
425
+ }
426
+ position += bytesRead;
427
+ }
428
+ if (carry !== null || carryDropped) {
429
+ if (carry !== null) state.incomplete.add('unfinished-tail');
430
+ state.indexedBytes = carryStart;
431
+ } else {
432
+ state.indexedBytes = position;
433
+ }
434
+ }
435
+
436
+ _consumeLine(state, offset, endOffset, lineBuffer) {
437
+ if (offset === 0) {
438
+ this._consumeHeader(state, lineBuffer);
439
+ return;
440
+ }
441
+ const event = this._parseLine(state, offset, lineBuffer);
442
+ if (event === null) return;
443
+ if (event.type === 'workbench/history-checkpoint') return;
444
+ if (event.type === 'workbench/history-gap') {
445
+ state.incomplete.add('history-gap');
446
+ return;
447
+ }
448
+ state.index.observe({ offset, endOffset, event });
449
+ }
450
+
451
+ _consumeHeader(state, lineBuffer) {
452
+ const header = this._parseLine(state, 0, lineBuffer);
453
+ if (header === null || header.schema !== HISTORY_SCHEMA || !isId(header.sessionId) || !isId(header.contextId)) {
454
+ fail('HISTORY_HEADER_INVALID', 400, 'history file does not begin with a valid workbench-history/v1 header');
455
+ }
456
+ if (header.sessionId !== this._scope.parentSessionId || header.contextId !== this._scope.contextId) {
457
+ fail('HISTORY_OWNERSHIP_MISMATCH', 409, 'history file belongs to a different session scope');
458
+ }
459
+ state.fileId = isId(header.fileId) ? header.fileId : null;
460
+ if (header.incomplete === true) state.incomplete.add('header-incomplete');
461
+ }
462
+
463
+ _parseLine(state, offset, lineBuffer) {
464
+ let end = lineBuffer.length;
465
+ if (end > 0 && lineBuffer[end - 1] === 0x0A) end--;
466
+ if (end > 0 && lineBuffer[end - 1] === 0x0D) end--;
467
+ let parsed = null;
468
+ try { parsed = JSON.parse(lineBuffer.toString('utf8', 0, end)); } catch (error) { parsed = null; }
469
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
470
+ this._diag(state, 'scan-corrupt-row', { offset });
471
+ state.incomplete.add('corrupt-row');
472
+ return null;
473
+ }
474
+ return parsed;
475
+ }
476
+
477
+ _diag(state, code, ids) {
478
+ if (state.diagnostics.length >= READER_DIAGNOSTIC_CAP) return;
479
+ state.diagnostics.push(Object.assign({ code }, ids || {}));
480
+ }
481
+
482
+ _diagnosticView(state) {
483
+ const own = state.diagnostics.map(item => Object.assign({}, item, { source: 'reader' }));
484
+ const indexed = state.index.diagnostics().map(item => Object.assign({}, item, { source: 'index' }));
485
+ const all = own.concat(indexed);
486
+ return {
487
+ items: all.slice(0, RESPONSE_DIAGNOSTIC_CAP),
488
+ total: all.length,
489
+ truncated: all.length > RESPONSE_DIAGNOSTIC_CAP,
490
+ };
491
+ }
492
+ }
493
+
494
+ function nativeTaskHistoryCacheStats() {
495
+ const readers = [];
496
+ for (const entry of readerCache.values()) {
497
+ const state = entry.state;
498
+ if (!state) continue;
499
+ readers.push({
500
+ file: state.file,
501
+ scope: state.scope,
502
+ fileId: state.fileId,
503
+ fileSize: state.fileSize,
504
+ indexedBytes: state.indexedBytes,
505
+ incomplete: [...state.incomplete],
506
+ index: state.index.stats(),
507
+ });
508
+ }
509
+ return { entries: readerCache.size, maxEntries: CACHE_MAX_READERS, readers };
510
+ }
511
+
512
+ function createNativeTaskHistoryReader(options) { return new NativeTaskHistoryReader(options); }
513
+
514
+ module.exports = {
515
+ NativeTaskHistoryReader,
516
+ createNativeTaskHistoryReader,
517
+ nativeTaskHistoryCacheStats,
518
+ NATIVE_TASK_HISTORY_READER_LIMITS: Object.freeze({
519
+ defaultLimit: DEFAULT_LIMIT,
520
+ maxLimit: MAX_LIMIT,
521
+ maxPageBytes: MAX_PAGE_BYTES,
522
+ maxRowBytes: MAX_ROW_BYTES,
523
+ scanChunkBytes: SCAN_CHUNK_BYTES,
524
+ maxCursorChars: MAX_CURSOR_CHARS,
525
+ }),
526
+ };