@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.
- package/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +636 -613
- package/apps/agent-terminal/app.js +125 -65
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +171 -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 +187 -159
- package/apps/agent-terminal/right-workspace-dock.js +623 -519
- 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 +13 -13
- package/docs/sdk/openapi.yaml +8 -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 +1442 -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/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 +2392 -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,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
|
+
};
|