@adecore/agents 0.23.1 → 0.24.1
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/README.md +3 -3
- package/dist/chat/attachment-store.js +4 -5
- package/dist/chat/backend.d.ts +6 -1
- package/dist/chat/background-work.d.ts +1 -1
- package/dist/chat/background-work.js +2 -1
- package/dist/chat/chat-core.d.ts +53 -161
- package/dist/chat/chat-core.js +146 -112
- package/dist/chat/chat-log.js +3 -3
- package/dist/chat/chat-process.d.ts +21 -0
- package/dist/chat/chat-process.js +109 -9
- package/dist/chat/chat-session.d.ts +10 -17
- package/dist/chat/chat-session.js +141 -182
- package/dist/chat/chat-store.d.ts +1 -0
- package/dist/chat/chat-store.js +105 -1
- package/dist/chat/claude-backend.d.ts +4 -0
- package/dist/chat/claude-backend.js +51 -27
- package/dist/chat/claude-protocol.d.ts +12 -0
- package/dist/chat/claude-protocol.js +137 -7
- package/dist/chat/claude-title.d.ts +1 -3
- package/dist/chat/claude-title.js +11 -39
- package/dist/chat/claude-transcript.d.ts +1 -0
- package/dist/chat/claude-transcript.js +36 -17
- package/dist/chat/codex-backend.d.ts +21 -1
- package/dist/chat/codex-backend.js +168 -10
- package/dist/chat/codex-protocol.d.ts +2 -0
- package/dist/chat/codex-protocol.js +37 -3
- package/dist/chat/codex-thread.js +1 -3
- package/dist/chat/codex-transport.d.ts +3 -0
- package/dist/chat/codex-transport.js +38 -35
- package/dist/chat/context-breakdown.js +2 -2
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/external-sessions.js +2 -2
- package/dist/chat/fixtures/claude-2.1.296-goal.json +136 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
- package/dist/chat/index.d.ts +29 -0
- package/dist/chat/index.js +23 -0
- package/dist/chat/projector.d.ts +2 -1
- package/dist/chat/projector.js +12 -3
- package/dist/chat/request-summary.js +1 -4
- package/dist/chat/subagent-settlement.js +1 -3
- package/dist/chat/thread.d.ts +3 -0
- package/dist/chat/thread.js +20 -3
- package/dist/chat/ui-queries.d.ts +2 -0
- package/dist/chat/ui-queries.js +40 -5
- package/dist/chat/ui-replies.d.ts +33 -0
- package/dist/chat/ui-replies.js +152 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +3 -0
- package/dist/coordination/index.d.ts +15 -0
- package/dist/coordination/index.js +16 -0
- package/dist/host/handlers.js +8 -0
- package/dist/host/index.d.ts +7 -0
- package/dist/host/index.js +7 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +10 -0
- package/dist/providers/accounts/status.js +1 -3
- package/dist/providers/claude.js +3 -1
- package/dist/providers/codex.js +3 -1
- package/dist/providers/index.d.ts +14 -0
- package/dist/providers/index.js +12 -0
- package/dist/run-process.js +54 -14
- package/dist/skills.d.ts +1 -0
- package/dist/skills.js +4 -0
- package/dist/tasks/task-coordinator.d.ts +4 -0
- package/dist/tasks/task-coordinator.js +16 -2
- package/dist/tasks/wiring.js +7 -1
- package/dist/{providers/accounts/test-accounts.d.ts → testing/accounts.d.ts} +2 -2
- package/dist/{providers/accounts/test-accounts.js → testing/accounts.js} +3 -3
- package/dist/testing/bin/fake-claude.d.ts +1 -0
- package/dist/testing/bin/fake-claude.js +6 -0
- package/dist/testing/bin/fake-codex.d.ts +1 -0
- package/dist/testing/bin/fake-codex.js +6 -0
- package/dist/{chat → testing}/fake-claude.d.ts +1 -1
- package/dist/{chat → testing}/fake-claude.js +0 -4
- package/dist/{chat → testing}/fake-cli.d.ts +1 -1
- package/dist/{chat → testing}/fake-codex.d.ts +1 -1
- package/dist/{chat → testing}/fake-codex.js +70 -8
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.js +7 -0
- package/dist/{outbox → testing}/manual-clock.d.ts +1 -1
- package/dist/{watch-test-helpers.d.ts → testing/watch.d.ts} +1 -1
- package/dist/usage/exchange.d.ts +5 -3
- package/dist/usage/exchange.js +25 -16
- package/dist/usage/index-file.d.ts +1 -0
- package/dist/usage/index-file.js +20 -2
- package/dist/usage/index.d.ts +9 -0
- package/dist/usage/index.js +6 -0
- package/dist/usage/limits/normalize.js +1 -3
- package/dist/usage/pricing.d.ts +5 -3
- package/dist/usage/pricing.js +28 -19
- package/dist/usage/scanner.js +39 -11
- package/dist/usage/usage-service.js +8 -3
- package/package.json +72 -11
- package/src/chat/attachment-store.ts +4 -6
- package/src/chat/backend.ts +8 -0
- package/src/chat/background-work.ts +2 -2
- package/src/chat/chat-core.ts +221 -143
- package/src/chat/chat-log.ts +3 -3
- package/src/chat/chat-process.ts +128 -8
- package/src/chat/chat-session.ts +153 -204
- package/src/chat/chat-store.ts +104 -1
- package/src/chat/claude-backend.ts +58 -26
- package/src/chat/claude-protocol.ts +147 -8
- package/src/chat/claude-title.ts +11 -39
- package/src/chat/claude-transcript.ts +38 -18
- package/src/chat/codex-backend.ts +182 -20
- package/src/chat/codex-protocol.ts +40 -5
- package/src/chat/codex-thread.ts +1 -4
- package/src/chat/codex-transport.ts +45 -35
- package/src/chat/context-breakdown.ts +2 -2
- package/src/chat/errors.ts +1 -0
- package/src/chat/external-sessions.ts +2 -2
- package/src/chat/fixtures/claude-2.1.296-goal.json +136 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
- package/src/chat/index.ts +49 -0
- package/src/chat/projector.ts +13 -4
- package/src/chat/request-summary.ts +8 -5
- package/src/chat/subagent-settlement.ts +1 -4
- package/src/chat/thread.ts +22 -3
- package/src/chat/ui-queries.ts +46 -5
- package/src/chat/ui-replies.ts +187 -0
- package/src/context/index.ts +26 -0
- package/src/coordination/index.ts +67 -0
- package/src/host/handlers.ts +8 -0
- package/src/host/index.ts +8 -0
- package/src/index.ts +20 -0
- package/src/providers/accounts/status.ts +1 -4
- package/src/providers/claude.ts +3 -1
- package/src/providers/codex.ts +3 -1
- package/src/providers/index.ts +15 -0
- package/src/run-process.ts +56 -16
- package/src/skills.ts +5 -0
- package/src/tasks/task-coordinator.ts +22 -2
- package/src/tasks/wiring.ts +7 -1
- package/src/{providers/accounts/test-accounts.ts → testing/accounts.ts} +4 -4
- package/src/testing/bin/fake-claude.ts +7 -0
- package/src/testing/bin/fake-codex.ts +7 -0
- package/src/{chat → testing}/fake-claude.ts +1 -5
- package/src/{chat → testing}/fake-cli.ts +1 -1
- package/src/{chat → testing}/fake-codex.ts +75 -6
- package/src/testing/index.ts +7 -0
- package/src/{outbox → testing}/manual-clock.ts +1 -1
- package/src/{watch-test-helpers.ts → testing/watch.ts} +1 -1
- package/src/usage/exchange.ts +31 -20
- package/src/usage/index-file.ts +23 -2
- package/src/usage/index.ts +10 -0
- package/src/usage/limits/normalize.ts +1 -4
- package/src/usage/pricing.ts +34 -23
- package/src/usage/scanner.ts +48 -11
- package/src/usage/usage-service.ts +8 -3
- /package/dist/{chat → testing}/fake-cli.js +0 -0
- /package/dist/{outbox → testing}/manual-clock.js +0 -0
- /package/dist/{watch-test-helpers.js → testing/watch.js} +0 -0
package/src/usage/exchange.ts
CHANGED
|
@@ -32,8 +32,8 @@ export class ExchangeRates {
|
|
|
32
32
|
private readonly home: string;
|
|
33
33
|
private readonly allowFetch: boolean;
|
|
34
34
|
private rate: UsageRate | null = null;
|
|
35
|
-
private
|
|
36
|
-
private inFlight: Promise<
|
|
35
|
+
private stored: Promise<void> | null = null;
|
|
36
|
+
private inFlight: Promise<boolean> | null = null;
|
|
37
37
|
|
|
38
38
|
constructor(home: string, allowFetch = true) {
|
|
39
39
|
this.home = home;
|
|
@@ -44,11 +44,19 @@ export class ExchangeRates {
|
|
|
44
44
|
return this.rate;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/*
|
|
48
|
-
|
|
49
|
-
this.
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
/* Reads the rate an earlier run stored, once; asking for a new one is left to `ensure`. */
|
|
48
|
+
load(): Promise<void> {
|
|
49
|
+
this.stored ??= this.readStored();
|
|
50
|
+
return this.stored;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Refreshes at most one rate at a time, a second caller waiting for the first one's answer; true when a newer rate came in. */
|
|
54
|
+
ensure(): Promise<boolean> {
|
|
55
|
+
this.inFlight ??= this.load()
|
|
56
|
+
.then(() => this.fetchFresh())
|
|
57
|
+
.finally(() => {
|
|
58
|
+
this.inFlight = null;
|
|
59
|
+
});
|
|
52
60
|
return this.inFlight;
|
|
53
61
|
}
|
|
54
62
|
|
|
@@ -56,23 +64,24 @@ export class ExchangeRates {
|
|
|
56
64
|
return join(this.home, 'usage', 'exchange-rate.json');
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
private async
|
|
60
|
-
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (!isNotFound(e)) {
|
|
69
|
-
console.warn('The stored exchange rate would not read; asking for a new one:', errorText(e));
|
|
70
|
-
}
|
|
67
|
+
private async readStored(): Promise<void> {
|
|
68
|
+
try {
|
|
69
|
+
const stored = JSON.parse(await readFile(this.file, 'utf8')) as UsageRate;
|
|
70
|
+
if (stored?.currency === TARGET_CURRENCY && typeof stored.rate === 'number' && stored.rate > 0) {
|
|
71
|
+
this.rate = stored;
|
|
72
|
+
}
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (!isNotFound(e)) {
|
|
75
|
+
console.warn('The stored exchange rate would not read; asking for a new one:', errorText(e));
|
|
71
76
|
}
|
|
72
77
|
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async fetchFresh(): Promise<boolean> {
|
|
73
81
|
if (!this.allowFetch || (this.rate !== null && Date.now() - this.rate.fetchedAt < TTL_MS)) {
|
|
74
|
-
return;
|
|
82
|
+
return false;
|
|
75
83
|
}
|
|
84
|
+
let changed = false;
|
|
76
85
|
try {
|
|
77
86
|
const response = await fetch(`${FRANKFURTER_URL}?from=USD&to=${TARGET_CURRENCY}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
78
87
|
if (!response.ok) {
|
|
@@ -83,11 +92,13 @@ export class ExchangeRates {
|
|
|
83
92
|
throw new Error(`The answer carried no ${TARGET_CURRENCY} rate`);
|
|
84
93
|
}
|
|
85
94
|
this.rate = { ...parsed, fetchedAt: Date.now() };
|
|
95
|
+
changed = true;
|
|
86
96
|
await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
|
|
87
97
|
await writeAtomic(this.file, JSON.stringify(this.rate));
|
|
88
98
|
} catch (e) {
|
|
89
99
|
// Yesterday's rate still converts; a rate that could not be refreshed is not a failure.
|
|
90
100
|
console.warn('Could not refresh the exchange rate:', errorText(e));
|
|
91
101
|
}
|
|
102
|
+
return changed;
|
|
92
103
|
}
|
|
93
104
|
}
|
package/src/usage/index-file.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface IndexedFile {
|
|
|
19
19
|
tail: UsageRecord[];
|
|
20
20
|
/* What the reader has to remember to carry on halfway through a Codex file. */
|
|
21
21
|
codex: CodexParserState | null;
|
|
22
|
+
/* A versioned hash of all bytes before the offset; absent in indexes that cannot verify an append. */
|
|
23
|
+
head: string | null;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export type UsageIndex = Map<string, IndexedFile>;
|
|
@@ -39,6 +41,7 @@ interface FileRow {
|
|
|
39
41
|
r: RecordRow[];
|
|
40
42
|
t: RecordRow[];
|
|
41
43
|
c: CodexParserState | null;
|
|
44
|
+
h?: string;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
interface IndexFile {
|
|
@@ -88,7 +91,16 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
88
91
|
record.account === undefined ? 0 : accounts.index(record.account) + 1
|
|
89
92
|
];
|
|
90
93
|
for (const [path, file] of index) {
|
|
91
|
-
files[path] = {
|
|
94
|
+
files[path] = {
|
|
95
|
+
p: file.provider,
|
|
96
|
+
s: file.size,
|
|
97
|
+
m: file.mtimeMs,
|
|
98
|
+
o: file.offset,
|
|
99
|
+
c: file.codex,
|
|
100
|
+
r: file.records.map(row),
|
|
101
|
+
t: file.tail.map(row),
|
|
102
|
+
...(file.head === null ? {} : { h: file.head })
|
|
103
|
+
};
|
|
92
104
|
}
|
|
93
105
|
const document: IndexFile = {
|
|
94
106
|
version: INDEX_VERSION,
|
|
@@ -171,7 +183,16 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
171
183
|
const records = decode(file.r);
|
|
172
184
|
const tail = decode(Array.isArray(file.t) ? file.t : []);
|
|
173
185
|
if (!broken) {
|
|
174
|
-
index.set(path, {
|
|
186
|
+
index.set(path, {
|
|
187
|
+
provider: file.p,
|
|
188
|
+
size: file.s,
|
|
189
|
+
mtimeMs: file.m,
|
|
190
|
+
offset: file.o,
|
|
191
|
+
records,
|
|
192
|
+
tail,
|
|
193
|
+
codex: file.c ?? null,
|
|
194
|
+
head: typeof file.h === 'string' ? file.h : null
|
|
195
|
+
});
|
|
175
196
|
}
|
|
176
197
|
}
|
|
177
198
|
return index;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
|
|
2
|
+
export { usageAccountsOf, usageRootsOf, limitAccountsOf, chatSessionAccounts } from './accounts.ts';
|
|
3
|
+
export { UsageMonitor, type ProbeEnv, type LimitAccount, type LimitAccounts, type UsageMonitorOptions } from './limits/monitor.ts';
|
|
4
|
+
export type { LimitsUpdate, ProviderReading } from './limits/normalize.ts';
|
|
5
|
+
export type { ProbeResult } from './limits/probe.ts';
|
|
6
|
+
export { LITELLM_URL } from './pricing.ts';
|
|
7
|
+
export type { KnownProject } from './projects.ts';
|
|
8
|
+
export { type UsageRootPath, usageRoots } from './roots.ts';
|
|
9
|
+
export type { UsageScannerOptions } from './scanner.ts';
|
|
10
|
+
export { UsageService, type UsageServiceOptions } from './usage-service.ts';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { UsageLimitsProvider, UsageProvider, UsageWindow } from '@adecore/agent-contracts';
|
|
2
|
+
import { isRecord } from '@adecore/agent-contracts';
|
|
2
3
|
|
|
3
4
|
/* Everything a full read of a provider says about its plan, ready to be published as it is. */
|
|
4
5
|
export interface ProviderReading {
|
|
@@ -21,10 +22,6 @@ const FIVE_HOURS_MS = 5 * 60 * MINUTE_MS;
|
|
|
21
22
|
const WEEK_MS = 7 * 24 * 60 * MINUTE_MS;
|
|
22
23
|
const MONTH_MS = 30 * 24 * 60 * MINUTE_MS;
|
|
23
24
|
|
|
24
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
25
|
function num(value: unknown): number | null {
|
|
29
26
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
30
27
|
}
|
package/src/usage/pricing.ts
CHANGED
|
@@ -164,8 +164,8 @@ export class PriceBook {
|
|
|
164
164
|
private table: PriceTable;
|
|
165
165
|
private source: UsagePricing['source'] = 'snapshot';
|
|
166
166
|
private fetchedAt: number | null = null;
|
|
167
|
-
private
|
|
168
|
-
private inFlight: Promise<
|
|
167
|
+
private stored: Promise<void> | null = null;
|
|
168
|
+
private inFlight: Promise<boolean> | null = null;
|
|
169
169
|
|
|
170
170
|
constructor(home: string, allowFetch = true) {
|
|
171
171
|
this.home = home;
|
|
@@ -181,11 +181,19 @@ export class PriceBook {
|
|
|
181
181
|
return lookupPrice(this.table, model);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
/*
|
|
185
|
-
|
|
186
|
-
this.
|
|
187
|
-
|
|
188
|
-
|
|
184
|
+
/* Reads the table an earlier run stored, once; asking LiteLLM is left to `ensure`. */
|
|
185
|
+
load(): Promise<void> {
|
|
186
|
+
this.stored ??= this.readStored();
|
|
187
|
+
return this.stored;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* Refreshes at most one table at a time, a second caller waiting for the first one's answer; true when a newer table came in. */
|
|
191
|
+
ensure(): Promise<boolean> {
|
|
192
|
+
this.inFlight ??= this.load()
|
|
193
|
+
.then(() => this.fetchFresh())
|
|
194
|
+
.finally(() => {
|
|
195
|
+
this.inFlight = null;
|
|
196
|
+
});
|
|
189
197
|
return this.inFlight;
|
|
190
198
|
}
|
|
191
199
|
|
|
@@ -193,26 +201,27 @@ export class PriceBook {
|
|
|
193
201
|
return join(this.home, 'usage', 'prices.json');
|
|
194
202
|
}
|
|
195
203
|
|
|
196
|
-
private async
|
|
197
|
-
|
|
198
|
-
this.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (!isNotFound(e)) {
|
|
209
|
-
console.warn('The stored price table would not read; using the bundled one:', errorText(e));
|
|
210
|
-
}
|
|
204
|
+
private async readStored(): Promise<void> {
|
|
205
|
+
try {
|
|
206
|
+
const snapshot = JSON.parse(await readFile(this.file, 'utf8')) as Snapshot;
|
|
207
|
+
const table = parsePriceTable(snapshot?.document);
|
|
208
|
+
if (table.size > 0 && typeof snapshot.fetchedAt === 'number') {
|
|
209
|
+
this.table = table;
|
|
210
|
+
this.source = 'litellm';
|
|
211
|
+
this.fetchedAt = snapshot.fetchedAt;
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (!isNotFound(e)) {
|
|
215
|
+
console.warn('The stored price table would not read; using the bundled one:', errorText(e));
|
|
211
216
|
}
|
|
212
217
|
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async fetchFresh(): Promise<boolean> {
|
|
213
221
|
if (!this.allowFetch || (this.fetchedAt !== null && Date.now() - this.fetchedAt < TTL_MS)) {
|
|
214
|
-
return;
|
|
222
|
+
return false;
|
|
215
223
|
}
|
|
224
|
+
let changed = false;
|
|
216
225
|
try {
|
|
217
226
|
const response = await fetch(LITELLM_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
218
227
|
if (!response.ok) {
|
|
@@ -226,11 +235,13 @@ export class PriceBook {
|
|
|
226
235
|
this.table = table;
|
|
227
236
|
this.source = 'litellm';
|
|
228
237
|
this.fetchedAt = Date.now();
|
|
238
|
+
changed = true;
|
|
229
239
|
await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
|
|
230
240
|
await writeAtomic(this.file, JSON.stringify({ fetchedAt: this.fetchedAt, document } satisfies Snapshot));
|
|
231
241
|
} catch (e) {
|
|
232
242
|
// Whatever is loaded keeps pricing; a table that could not be refreshed is not a failure.
|
|
233
243
|
console.warn('Could not refresh the price table:', errorText(e));
|
|
234
244
|
}
|
|
245
|
+
return changed;
|
|
235
246
|
}
|
|
236
247
|
}
|
package/src/usage/scanner.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash, type Hash } from 'node:crypto';
|
|
1
2
|
import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import type { UsageRoot } from '@adecore/agent-contracts';
|
|
@@ -9,26 +10,55 @@ import { claudeMightCarryUsage, parseClaudeLine } from './readers/claude.ts';
|
|
|
9
10
|
import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine, type CodexParserState } from './readers/codex.ts';
|
|
10
11
|
import { usageRoots, type UsageRootPath } from './roots.ts';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
const HASH_CHUNK_BYTES = 65536;
|
|
14
|
+
|
|
15
|
+
interface HashedRead {
|
|
16
|
+
/* Everything from `from` to the end of the file, as it is at the read. */
|
|
17
|
+
bytes: Buffer;
|
|
18
|
+
/* Over the bytes before `from`, left open so the caller can carry it on over `bytes`. */
|
|
19
|
+
hash: Hash;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* Hashes the bytes before `from` and returns the rest, in one pass over one handle, so verifying
|
|
24
|
+
* what a transcript committed and parsing what it added never reads a byte twice.
|
|
25
|
+
*/
|
|
26
|
+
async function readHashed(path: string, from: number): Promise<HashedRead> {
|
|
14
27
|
const handle = await open(path, 'r');
|
|
15
28
|
try {
|
|
29
|
+
const hash = createHash('sha256');
|
|
30
|
+
const chunk = Buffer.alloc(Math.min(from, HASH_CHUNK_BYTES));
|
|
31
|
+
let offset = 0;
|
|
32
|
+
while (offset < from) {
|
|
33
|
+
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, from - offset), offset);
|
|
34
|
+
if (bytesRead === 0) {
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
hash.update(chunk.subarray(0, bytesRead));
|
|
38
|
+
offset += bytesRead;
|
|
39
|
+
}
|
|
16
40
|
const { size } = await handle.stat();
|
|
17
|
-
const
|
|
41
|
+
const bytes = Buffer.alloc(Math.max(0, size - from));
|
|
18
42
|
let filled = 0;
|
|
19
|
-
while (filled <
|
|
20
|
-
const { bytesRead } = await handle.read(
|
|
43
|
+
while (filled < bytes.length) {
|
|
44
|
+
const { bytesRead } = await handle.read(bytes, filled, bytes.length - filled, from + filled);
|
|
21
45
|
if (bytesRead === 0) {
|
|
22
46
|
break;
|
|
23
47
|
}
|
|
24
48
|
filled += bytesRead;
|
|
25
49
|
}
|
|
26
|
-
return
|
|
50
|
+
return { bytes: bytes.subarray(0, filled), hash };
|
|
27
51
|
} finally {
|
|
28
52
|
await handle.close();
|
|
29
53
|
}
|
|
30
54
|
}
|
|
31
55
|
|
|
56
|
+
/* Covers every committed byte, since a transcript can be rewritten after an unchanged session header. */
|
|
57
|
+
function headOf(hash: Hash): string {
|
|
58
|
+
// Older indexes hashed only the first 4 KB; they cannot establish that the rest stayed unchanged.
|
|
59
|
+
return `sha256:${hash.copy().digest('base64')}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
export interface ScanReport {
|
|
33
63
|
at: number;
|
|
34
64
|
files: number;
|
|
@@ -137,7 +167,7 @@ function withAccount(records: UsageRecord[], accountOf: AccountOf): UsageRecord[
|
|
|
137
167
|
}
|
|
138
168
|
|
|
139
169
|
/*
|
|
140
|
-
*
|
|
170
|
+
* Parses only what a transcript appended since the last scan, and starts a file over once it was
|
|
141
171
|
* rewritten or cut. A deleted transcript keeps its totals, since its usage still happened.
|
|
142
172
|
*/
|
|
143
173
|
export class UsageScanner {
|
|
@@ -241,10 +271,16 @@ export class UsageScanner {
|
|
|
241
271
|
if (known !== undefined && known.provider === provider && known.size === info.size && known.mtimeMs === info.mtimeMs) {
|
|
242
272
|
return false;
|
|
243
273
|
}
|
|
244
|
-
const
|
|
274
|
+
const appended = known !== undefined && known.provider === provider && info.size > known.size && known.head?.startsWith('sha256:') === true;
|
|
275
|
+
let read = await readHashed(path, appended ? known.offset : 0);
|
|
276
|
+
// A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
|
|
277
|
+
const grown = appended && headOf(read.hash) === known.head;
|
|
278
|
+
if (appended && !grown) {
|
|
279
|
+
read = await readHashed(path, 0);
|
|
280
|
+
}
|
|
245
281
|
const from = grown ? known.offset : 0;
|
|
246
|
-
const
|
|
247
|
-
|
|
282
|
+
const parsed = parseChunk(read.bytes.toString('utf8'), provider, from, grown ? known.codex : null);
|
|
283
|
+
read.hash.update(read.bytes.subarray(0, parsed.offset - from));
|
|
248
284
|
const before = grown ? known.records : [];
|
|
249
285
|
const entry: IndexedFile = {
|
|
250
286
|
provider,
|
|
@@ -254,7 +290,8 @@ export class UsageScanner {
|
|
|
254
290
|
// Folding here keeps the streaming copies of one message out of the index entirely.
|
|
255
291
|
records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
|
|
256
292
|
tail: withAccount(parsed.tail, accountOf),
|
|
257
|
-
codex: parsed.codex
|
|
293
|
+
codex: parsed.codex,
|
|
294
|
+
head: headOf(read.hash)
|
|
258
295
|
};
|
|
259
296
|
this.index.set(path, entry);
|
|
260
297
|
this.dirty = true;
|
|
@@ -135,14 +135,19 @@ export class UsageService {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
private async runScan(): Promise<void> {
|
|
138
|
-
//
|
|
138
|
+
// A fetch never holds up the scan or the answer waiting on it; a table or rate it brings in is announced on its own.
|
|
139
|
+
void Promise.all([this.prices.ensure(), this.rates.ensure()]).then((fetched) => {
|
|
140
|
+
if (fetched.includes(true)) {
|
|
141
|
+
this.sinks.emit({ event: 'usage.changed', payload: { scannedAt: this.report.at } });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
139
144
|
const [report] = await Promise.all([
|
|
140
145
|
this.scanner.scan().catch((e: unknown) => {
|
|
141
146
|
console.error('The usage scan failed:', errorText(e));
|
|
142
147
|
return null;
|
|
143
148
|
}),
|
|
144
|
-
this.prices.
|
|
145
|
-
this.rates.
|
|
149
|
+
this.prices.load(),
|
|
150
|
+
this.rates.load()
|
|
146
151
|
]);
|
|
147
152
|
this.failed = report === null;
|
|
148
153
|
if (report === null) {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|