@shi-yangyang/dsh-token-dashboard 0.0.0-stage → 0.2.0
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 +21 -0
- package/README.en.md +166 -0
- package/README.md +165 -2
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/client/client.js +22897 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +13 -0
- package/lib/host/aggregate.d.ts +61 -0
- package/lib/host/aggregate.js +472 -0
- package/lib/host/fold.d.ts +103 -0
- package/lib/host/fold.js +375 -0
- package/lib/host/index.d.ts +24 -0
- package/lib/host/index.js +116 -0
- package/lib/host/prices.d.ts +42 -0
- package/lib/host/prices.js +118 -0
- package/lib/host/server.d.ts +30 -0
- package/lib/host/server.js +177 -0
- package/lib/host/source.d.ts +37 -0
- package/lib/host/source.js +179 -0
- package/lib/host/types.d.ts +133 -0
- package/lib/host/types.js +9 -0
- package/lib/host/verify.d.ts +14 -0
- package/lib/host/verify.js +79 -0
- package/lib/shared/builtin-prices.d.ts +27 -0
- package/lib/shared/builtin-prices.generated.d.ts +7 -0
- package/lib/shared/builtin-prices.generated.js +1433 -0
- package/lib/shared/builtin-prices.js +111 -0
- package/lib/shared/dates.d.ts +23 -0
- package/lib/shared/dates.js +79 -0
- package/lib/shared/export.d.ts +13 -0
- package/lib/shared/export.js +113 -0
- package/lib/shared/format.d.ts +23 -0
- package/lib/shared/format.js +108 -0
- package/lib/shared/model.d.ts +37 -0
- package/lib/shared/model.js +87 -0
- package/lib/shared/pricing.d.ts +70 -0
- package/lib/shared/pricing.js +192 -0
- package/lib/shared/range.d.ts +34 -0
- package/lib/shared/range.js +93 -0
- package/lib/shared/types.d.ts +168 -0
- package/lib/shared/types.js +10 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +93 -4
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP surface for the dashboard.
|
|
3
|
+
*
|
|
4
|
+
* One same-origin, read-only prefix route:
|
|
5
|
+
* GET /dsh-token-dashboard/stats dashboard payload (ETag, ?refresh=1)
|
|
6
|
+
* GET /dsh-token-dashboard/verify fold vs official tokenUsage projection
|
|
7
|
+
* GET /dsh-token-dashboard/requests paginated billed request log
|
|
8
|
+
* GET /dsh-token-dashboard/prices optional online list prices (explicit refresh)
|
|
9
|
+
* GET /dsh-token-dashboard/health liveness and revision
|
|
10
|
+
*
|
|
11
|
+
* The route reuses the Host Connection gate when the deployment publishes one,
|
|
12
|
+
* so a remote or logged-out peer is refused exactly like the Host API.
|
|
13
|
+
*/
|
|
14
|
+
import type { StatsPayload } from '../shared/types.js';
|
|
15
|
+
import type { TokenAggregator } from './aggregate.js';
|
|
16
|
+
import type { AggregatorSource } from './source.js';
|
|
17
|
+
import type { PriceLookup } from './prices.js';
|
|
18
|
+
import type { ConnectionLike, WebServerLike } from './types.js';
|
|
19
|
+
/** Dependencies for the route registration. */
|
|
20
|
+
export interface DashboardRoutesOptions {
|
|
21
|
+
server: WebServerLike;
|
|
22
|
+
aggregator: TokenAggregator;
|
|
23
|
+
source: AggregatorSource;
|
|
24
|
+
connection?: () => ConnectionLike | undefined;
|
|
25
|
+
prices?: PriceLookup;
|
|
26
|
+
}
|
|
27
|
+
/** Register the prefix route; returns its disposer. */
|
|
28
|
+
export declare function registerDashboardRoutes(options: DashboardRoutesOptions): () => void;
|
|
29
|
+
/** Type guard for tests: a payload carrying the required top-level fields. */
|
|
30
|
+
export declare function isStatsPayload(value: unknown): value is StatsPayload;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP surface for the dashboard.
|
|
3
|
+
*
|
|
4
|
+
* One same-origin, read-only prefix route:
|
|
5
|
+
* GET /dsh-token-dashboard/stats dashboard payload (ETag, ?refresh=1)
|
|
6
|
+
* GET /dsh-token-dashboard/verify fold vs official tokenUsage projection
|
|
7
|
+
* GET /dsh-token-dashboard/requests paginated billed request log
|
|
8
|
+
* GET /dsh-token-dashboard/prices optional online list prices (explicit refresh)
|
|
9
|
+
* GET /dsh-token-dashboard/health liveness and revision
|
|
10
|
+
*
|
|
11
|
+
* The route reuses the Host Connection gate when the deployment publishes one,
|
|
12
|
+
* so a remote or logged-out peer is refused exactly like the Host API.
|
|
13
|
+
*/
|
|
14
|
+
import { API_PREFIX, API_VERSION } from '../shared/types.js';
|
|
15
|
+
import { verifyAgainstProjection } from './verify.js';
|
|
16
|
+
/** Register the prefix route; returns its disposer. */
|
|
17
|
+
export function registerDashboardRoutes(options) {
|
|
18
|
+
return options.server.register({
|
|
19
|
+
kind: 'prefix',
|
|
20
|
+
path: API_PREFIX,
|
|
21
|
+
handler: async (req, res) => {
|
|
22
|
+
try {
|
|
23
|
+
if (refusedByHost(req, res, options.connection))
|
|
24
|
+
return;
|
|
25
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
26
|
+
const suffix = routeSuffix(url.pathname);
|
|
27
|
+
if (suffix === undefined) {
|
|
28
|
+
sendError(res, 404, 'not-found', 'Unknown dashboard route.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
32
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
33
|
+
res.setHeader('allow', 'GET, HEAD');
|
|
34
|
+
sendError(res, 405, 'method-not-allowed', 'The dashboard API is read-only.');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const headOnly = method === 'HEAD';
|
|
38
|
+
if (suffix === '/health') {
|
|
39
|
+
sendJson(res, 200, {
|
|
40
|
+
ok: true,
|
|
41
|
+
apiVersion: API_VERSION,
|
|
42
|
+
initialized: options.aggregator.isInitialized,
|
|
43
|
+
revision: options.aggregator.getRevision(),
|
|
44
|
+
}, headOnly);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (suffix === '/stats') {
|
|
48
|
+
if (url.searchParams.get('refresh') === '1')
|
|
49
|
+
await options.aggregator.refresh();
|
|
50
|
+
else
|
|
51
|
+
await options.aggregator.ensure();
|
|
52
|
+
const payload = options.aggregator.stats();
|
|
53
|
+
const etag = `W/"tdb-${payload.apiVersion}-${payload.revision}"`;
|
|
54
|
+
if (matchesEtag(req, etag)) {
|
|
55
|
+
res.writeHead(304, {
|
|
56
|
+
etag,
|
|
57
|
+
'cache-control': 'no-store',
|
|
58
|
+
});
|
|
59
|
+
res.end();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
sendJson(res, 200, payload, headOnly, { etag });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (suffix === '/requests') {
|
|
66
|
+
if (url.searchParams.get('refresh') === '1')
|
|
67
|
+
await options.aggregator.refresh();
|
|
68
|
+
else
|
|
69
|
+
await options.aggregator.ensure();
|
|
70
|
+
const offset = Number(url.searchParams.get('offset') ?? '0');
|
|
71
|
+
const limit = Number(url.searchParams.get('limit') ?? '24');
|
|
72
|
+
sendJson(res, 200, options.aggregator.requestPage(offset, limit), headOnly);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (suffix === '/prices') {
|
|
76
|
+
if (options.prices === undefined) {
|
|
77
|
+
sendError(res, 503, 'prices-unavailable', 'Online prices are not configured.');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const rawModels = url.searchParams.get('models') ?? '';
|
|
81
|
+
const models = [...new Set(rawModels
|
|
82
|
+
.split(',')
|
|
83
|
+
.map(model => model.trim())
|
|
84
|
+
.filter(model => model !== '' && model.length <= 160))]
|
|
85
|
+
.slice(0, 100);
|
|
86
|
+
try {
|
|
87
|
+
const payload = await options.prices.lookup(models, url.searchParams.get('refresh') === '1');
|
|
88
|
+
sendJson(res, 200, payload, headOnly);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
sendError(res, 502, 'prices-unavailable', 'Online prices are unavailable.');
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (suffix === '/verify') {
|
|
96
|
+
await options.aggregator.ensure();
|
|
97
|
+
const limit = Number(url.searchParams.get('limit') ?? '40');
|
|
98
|
+
const report = await verifyAgainstProjection(options.source, options.aggregator, limit);
|
|
99
|
+
sendJson(res, 200, report, headOnly);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
sendError(res, 404, 'not-found', 'Unknown dashboard route.');
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
console.warn(`[dsh-token-dashboard] request failed: ${safeErrorName(error)}`);
|
|
106
|
+
if (!res.headersSent) {
|
|
107
|
+
sendError(res, 500, 'internal', 'The token dashboard could not read session history.');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function routeSuffix(pathname) {
|
|
114
|
+
const index = pathname.lastIndexOf(API_PREFIX);
|
|
115
|
+
if (index === -1)
|
|
116
|
+
return undefined;
|
|
117
|
+
if (index !== 0 && pathname[index - 1] !== '/')
|
|
118
|
+
return undefined;
|
|
119
|
+
const suffix = pathname.slice(index + API_PREFIX.length);
|
|
120
|
+
if (suffix === '' || suffix === '/')
|
|
121
|
+
return '/';
|
|
122
|
+
return `/${suffix.replace(/^\/+/, '').replace(/\/+$/, '')}`;
|
|
123
|
+
}
|
|
124
|
+
function refusedByHost(req, res, connection) {
|
|
125
|
+
let status;
|
|
126
|
+
try {
|
|
127
|
+
status = connection?.()?.requestRejection?.(req);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
status = 403;
|
|
131
|
+
}
|
|
132
|
+
if (typeof status !== 'number')
|
|
133
|
+
return false;
|
|
134
|
+
res.writeHead(status, { 'cache-control': 'no-store' });
|
|
135
|
+
res.end();
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
function matchesEtag(req, etag) {
|
|
139
|
+
const raw = req.headers['if-none-match'];
|
|
140
|
+
const value = Array.isArray(raw) ? raw.join(',') : raw;
|
|
141
|
+
if (typeof value !== 'string')
|
|
142
|
+
return false;
|
|
143
|
+
return value.split(',').map(part => part.trim()).includes(etag);
|
|
144
|
+
}
|
|
145
|
+
function sendJson(res, status, payload, headOnly = false, extra = {}) {
|
|
146
|
+
const body = JSON.stringify(payload);
|
|
147
|
+
res.writeHead(status, {
|
|
148
|
+
'content-type': 'application/json; charset=utf-8',
|
|
149
|
+
'cache-control': 'no-store',
|
|
150
|
+
'x-content-type-options': 'nosniff',
|
|
151
|
+
...extra,
|
|
152
|
+
});
|
|
153
|
+
if (headOnly)
|
|
154
|
+
res.end();
|
|
155
|
+
else
|
|
156
|
+
res.end(body);
|
|
157
|
+
}
|
|
158
|
+
function sendError(res, status, code, message) {
|
|
159
|
+
sendJson(res, status, { error: { code, message } });
|
|
160
|
+
}
|
|
161
|
+
function safeErrorName(error) {
|
|
162
|
+
if (error instanceof Error) {
|
|
163
|
+
const code = error.code;
|
|
164
|
+
return typeof code === 'string' ? `${error.name}:${code}` : error.name;
|
|
165
|
+
}
|
|
166
|
+
return 'UnknownError';
|
|
167
|
+
}
|
|
168
|
+
/** Type guard for tests: a payload carrying the required top-level fields. */
|
|
169
|
+
export function isStatsPayload(value) {
|
|
170
|
+
if (value === null || typeof value !== 'object')
|
|
171
|
+
return false;
|
|
172
|
+
const record = value;
|
|
173
|
+
return record.apiVersion === API_VERSION
|
|
174
|
+
&& typeof record.revision === 'number'
|
|
175
|
+
&& Array.isArray(record.days)
|
|
176
|
+
&& Array.isArray(record.sessionRows);
|
|
177
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host adapter over the official DSH services:
|
|
3
|
+
* `sessionPersistence.list()/open()` for durable logs, `sessions.list()` for
|
|
4
|
+
* live sessions, and `sessionQuery.observeSession()` for the diagnostic
|
|
5
|
+
* cross-check against token-meter's `tokenUsage` projection.
|
|
6
|
+
*
|
|
7
|
+
* The adapter is read-only. It never appends, rewrites, flushes, or deletes a
|
|
8
|
+
* session log.
|
|
9
|
+
*/
|
|
10
|
+
import type { TokenBuckets } from '../shared/types.js';
|
|
11
|
+
import type { HostServiceAccess, RawSessionEvent, SessionHeaderLike } from './types.js';
|
|
12
|
+
/** One session visible to the aggregator, live or persisted. */
|
|
13
|
+
export interface SessionDescriptor {
|
|
14
|
+
id: string;
|
|
15
|
+
header: SessionHeaderLike;
|
|
16
|
+
live: boolean;
|
|
17
|
+
/** Live: total event count (`session.seq`). Persisted: `eventCount` when known. */
|
|
18
|
+
seq: number;
|
|
19
|
+
revision?: string;
|
|
20
|
+
eventCount?: number;
|
|
21
|
+
inheritedEventCount: number;
|
|
22
|
+
}
|
|
23
|
+
/** A full or tail read of one session log. */
|
|
24
|
+
export interface SessionReadResult {
|
|
25
|
+
header: SessionHeaderLike;
|
|
26
|
+
inheritedEventCount: number;
|
|
27
|
+
events: readonly RawSessionEvent[];
|
|
28
|
+
}
|
|
29
|
+
/** The aggregator's data source. Tests provide a fake implementation. */
|
|
30
|
+
export interface AggregatorSource {
|
|
31
|
+
list(): Promise<SessionDescriptor[]>;
|
|
32
|
+
read(descriptor: SessionDescriptor, from: number): Promise<SessionReadResult>;
|
|
33
|
+
projectUsage?(id: string): Promise<TokenBuckets | undefined>;
|
|
34
|
+
projectLabel?(cwd: string | undefined): string;
|
|
35
|
+
}
|
|
36
|
+
/** Build the DSH-backed source. */
|
|
37
|
+
export declare function createDshSource(access: HostServiceAccess): AggregatorSource;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host adapter over the official DSH services:
|
|
3
|
+
* `sessionPersistence.list()/open()` for durable logs, `sessions.list()` for
|
|
4
|
+
* live sessions, and `sessionQuery.observeSession()` for the diagnostic
|
|
5
|
+
* cross-check against token-meter's `tokenUsage` projection.
|
|
6
|
+
*
|
|
7
|
+
* The adapter is read-only. It never appends, rewrites, flushes, or deletes a
|
|
8
|
+
* session log.
|
|
9
|
+
*/
|
|
10
|
+
/** Build the DSH-backed source. */
|
|
11
|
+
export function createDshSource(access) {
|
|
12
|
+
let workspaceTitles = new Map();
|
|
13
|
+
function persistence() {
|
|
14
|
+
const service = access.persistence();
|
|
15
|
+
if (service === undefined)
|
|
16
|
+
throw new Error('sessionPersistence is unavailable');
|
|
17
|
+
return service;
|
|
18
|
+
}
|
|
19
|
+
function refreshWorkspaces() {
|
|
20
|
+
const next = new Map();
|
|
21
|
+
try {
|
|
22
|
+
for (const workspace of access.workspaces()?.list() ?? []) {
|
|
23
|
+
if (typeof workspace.path === 'string' && typeof workspace.title === 'string' && workspace.title.trim() !== '') {
|
|
24
|
+
next.set(trimTrailingSeparators(workspace.path), workspace.title);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// A workspace registry failure only costs nicer project labels.
|
|
30
|
+
}
|
|
31
|
+
workspaceTitles = next;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
async list() {
|
|
35
|
+
refreshWorkspaces();
|
|
36
|
+
const persisted = await persistence().list();
|
|
37
|
+
const descriptors = new Map();
|
|
38
|
+
for (const snapshot of persisted) {
|
|
39
|
+
const descriptor = fromSnapshot(snapshot);
|
|
40
|
+
if (descriptor !== undefined)
|
|
41
|
+
descriptors.set(descriptor.id, descriptor);
|
|
42
|
+
}
|
|
43
|
+
for (const live of access.sessions()?.list() ?? []) {
|
|
44
|
+
const previous = descriptors.get(live.id);
|
|
45
|
+
const inherited = Math.max(0, Math.trunc(Number(live.inheritedEventCount) || 0));
|
|
46
|
+
const seq = Math.max(0, Math.trunc(Number(live.seq) || 0));
|
|
47
|
+
descriptors.set(live.id, {
|
|
48
|
+
id: live.id,
|
|
49
|
+
header: live.header,
|
|
50
|
+
live: true,
|
|
51
|
+
seq,
|
|
52
|
+
inheritedEventCount: inherited,
|
|
53
|
+
...(previous?.revision === undefined ? {} : { revision: previous.revision }),
|
|
54
|
+
...(previous?.eventCount === undefined ? {} : { eventCount: previous.eventCount }),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return [...descriptors.values()];
|
|
58
|
+
},
|
|
59
|
+
async read(descriptor, from) {
|
|
60
|
+
if (descriptor.live) {
|
|
61
|
+
const live = access.sessions()?.get(descriptor.id);
|
|
62
|
+
if (live !== undefined) {
|
|
63
|
+
const total = Math.max(0, Math.trunc(Number(live.seq) || 0));
|
|
64
|
+
const start = Math.max(0, Math.min(Math.trunc(from), total));
|
|
65
|
+
const events = start >= total ? [] : live.snapshotEvents(start, total);
|
|
66
|
+
return {
|
|
67
|
+
header: live.header,
|
|
68
|
+
inheritedEventCount: Math.max(0, Math.trunc(Number(live.inheritedEventCount) || 0)),
|
|
69
|
+
events,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// The session was disposed between list() and read(): fall through to
|
|
73
|
+
// the durable log, which it has already flushed by disposal.
|
|
74
|
+
}
|
|
75
|
+
const handle = await persistence().open(descriptor.id, 'read');
|
|
76
|
+
try {
|
|
77
|
+
const result = await handle.read(Math.max(0, Math.trunc(from)));
|
|
78
|
+
return {
|
|
79
|
+
header: handle.header,
|
|
80
|
+
inheritedEventCount: Math.max(0, Math.trunc(Number(handle.inheritedEventCount) || 0)),
|
|
81
|
+
events: result.events,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await handle.close();
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async projectUsage(id) {
|
|
89
|
+
const query = access.sessionQuery();
|
|
90
|
+
if (query === undefined)
|
|
91
|
+
return undefined;
|
|
92
|
+
let observation;
|
|
93
|
+
try {
|
|
94
|
+
observation = await query.observeSession(id, { projectionMode: 'all' });
|
|
95
|
+
const values = asRecord(asRecord(observation.projections)?.values);
|
|
96
|
+
const usage = asRecord(values?.tokenUsage);
|
|
97
|
+
if (usage === undefined)
|
|
98
|
+
return undefined;
|
|
99
|
+
return {
|
|
100
|
+
uncachedInputTokens: count(usage.uncachedInputTokens),
|
|
101
|
+
cacheReadTokens: count(usage.cacheReadTokens),
|
|
102
|
+
cacheWriteTokens: count(usage.cacheWriteTokens),
|
|
103
|
+
outputTokens: count(usage.outputTokens),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
disposeObservation(observation);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
projectLabel(cwd) {
|
|
111
|
+
if (cwd === undefined || cwd.trim() === '')
|
|
112
|
+
return '';
|
|
113
|
+
const key = trimTrailingSeparators(cwd);
|
|
114
|
+
return workspaceTitles.get(key) ?? baseName(key);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function fromSnapshot(snapshot) {
|
|
119
|
+
const header = snapshot.header;
|
|
120
|
+
if (header === undefined || typeof header.id !== 'string' || header.id === '')
|
|
121
|
+
return undefined;
|
|
122
|
+
const eventCount = typeof snapshot.eventCount === 'number' && Number.isFinite(snapshot.eventCount)
|
|
123
|
+
? Math.max(0, Math.trunc(snapshot.eventCount))
|
|
124
|
+
: 0;
|
|
125
|
+
return {
|
|
126
|
+
id: header.id,
|
|
127
|
+
header,
|
|
128
|
+
live: false,
|
|
129
|
+
seq: eventCount,
|
|
130
|
+
eventCount,
|
|
131
|
+
inheritedEventCount: 0,
|
|
132
|
+
revision: typeof snapshot.revision === 'string' ? snapshot.revision : String(snapshot.revision),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function disposeObservation(observation) {
|
|
136
|
+
if (observation === undefined)
|
|
137
|
+
return;
|
|
138
|
+
const symbol = Symbol.dispose;
|
|
139
|
+
if (symbol !== undefined) {
|
|
140
|
+
const candidate = observation;
|
|
141
|
+
const disposer = candidate[symbol];
|
|
142
|
+
if (typeof disposer === 'function') {
|
|
143
|
+
try {
|
|
144
|
+
disposer.call(observation);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// The lease is best-effort released; a failed release must not mask
|
|
148
|
+
// the projection result or replace the operation's own error.
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (typeof observation.dispose === 'function') {
|
|
154
|
+
try {
|
|
155
|
+
observation.dispose();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// See above.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function trimTrailingSeparators(value) {
|
|
163
|
+
const trimmed = value.replace(/[\\/]+$/, '');
|
|
164
|
+
return trimmed === '' ? value : trimmed;
|
|
165
|
+
}
|
|
166
|
+
function baseName(value) {
|
|
167
|
+
const parts = trimTrailingSeparators(value).split(/[\\/]/);
|
|
168
|
+
return parts[parts.length - 1] || value;
|
|
169
|
+
}
|
|
170
|
+
function asRecord(value) {
|
|
171
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
172
|
+
return undefined;
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
function count(value) {
|
|
176
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
|
|
177
|
+
return 0;
|
|
178
|
+
return Math.round(value);
|
|
179
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural views of the Host services this plugin consumes.
|
|
3
|
+
*
|
|
4
|
+
* They mirror the published DSH contracts the plugin was verified against
|
|
5
|
+
* (`ctx.sessionPersistence`, `ctx.sessions`, `ctx.sessionQuery`,
|
|
6
|
+
* `ctx.webServer`, `ctx.connection`, `ctx.workspaceRegistry`) without importing
|
|
7
|
+
* an implementation package. The installed Harness remains the authority.
|
|
8
|
+
*/
|
|
9
|
+
/** One durable session event, as exposed by the persistence handle. */
|
|
10
|
+
export interface RawSessionEvent {
|
|
11
|
+
readonly type: string;
|
|
12
|
+
readonly seq: number;
|
|
13
|
+
readonly time: number;
|
|
14
|
+
readonly data?: unknown;
|
|
15
|
+
readonly surfaceOp?: unknown;
|
|
16
|
+
readonly ignorable?: true;
|
|
17
|
+
}
|
|
18
|
+
/** The immutable session header fields this plugin reads. */
|
|
19
|
+
export interface SessionHeaderLike {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly createdAt?: number;
|
|
22
|
+
readonly cwd?: string;
|
|
23
|
+
readonly parentSession?: string;
|
|
24
|
+
readonly isSeeded?: boolean;
|
|
25
|
+
readonly origin?: 'subagent';
|
|
26
|
+
readonly delegationDepth?: number;
|
|
27
|
+
readonly agentPreset?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Provider-reported usage, as logged on assistant events. */
|
|
30
|
+
export interface UsageSample {
|
|
31
|
+
readonly inputTokens?: number;
|
|
32
|
+
readonly outputTokens?: number;
|
|
33
|
+
readonly cacheReadTokens?: number;
|
|
34
|
+
readonly cacheWriteTokens?: number;
|
|
35
|
+
readonly totalTokens?: number;
|
|
36
|
+
readonly reasoningTokens?: number;
|
|
37
|
+
}
|
|
38
|
+
/** Persistence snapshot (`ctx.sessionPersistence.list()`). */
|
|
39
|
+
export interface PersistenceSnapshot {
|
|
40
|
+
readonly header: SessionHeaderLike;
|
|
41
|
+
readonly revision: string;
|
|
42
|
+
readonly eventCount?: number;
|
|
43
|
+
readonly sizeBytes?: number;
|
|
44
|
+
readonly formatStatus?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Read-only persistence handle, narrowed to what this plugin calls. */
|
|
47
|
+
export interface PersistenceHandle {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly header: SessionHeaderLike;
|
|
50
|
+
readonly inheritedEventCount: number;
|
|
51
|
+
read(offset?: number, length?: number): Promise<{
|
|
52
|
+
readonly events: readonly RawSessionEvent[];
|
|
53
|
+
}>;
|
|
54
|
+
close(): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
/** The persistence service face. */
|
|
57
|
+
export interface SessionPersistenceLike {
|
|
58
|
+
list(options?: {
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
}): Promise<readonly PersistenceSnapshot[]>;
|
|
61
|
+
open(id: string, access: 'read' | 'write', options?: {
|
|
62
|
+
signal?: AbortSignal;
|
|
63
|
+
}): Promise<PersistenceHandle>;
|
|
64
|
+
}
|
|
65
|
+
/** A live in-memory Session (`ctx.sessions.list()`). */
|
|
66
|
+
export interface LiveSessionLike {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly header: SessionHeaderLike;
|
|
69
|
+
readonly inheritedEventCount: number;
|
|
70
|
+
readonly seq: number;
|
|
71
|
+
snapshotEvents(from: number, to: number): readonly RawSessionEvent[];
|
|
72
|
+
}
|
|
73
|
+
/** Live session store. */
|
|
74
|
+
export interface SessionsLike {
|
|
75
|
+
list(): readonly LiveSessionLike[];
|
|
76
|
+
get(id: string): LiveSessionLike | undefined;
|
|
77
|
+
}
|
|
78
|
+
/** A session observation lease from `ctx.sessionQuery.observeSession()`. */
|
|
79
|
+
export interface SessionObservationLike {
|
|
80
|
+
readonly projections?: {
|
|
81
|
+
readonly values?: unknown;
|
|
82
|
+
};
|
|
83
|
+
dispose?: () => void;
|
|
84
|
+
}
|
|
85
|
+
/** Query service face used only by the diagnostic verification endpoint. */
|
|
86
|
+
export interface SessionQueryLike {
|
|
87
|
+
observeSession(id: string, options?: {
|
|
88
|
+
readonly projectionMode?: 'all' | 'none';
|
|
89
|
+
readonly signal?: AbortSignal;
|
|
90
|
+
}): Promise<SessionObservationLike>;
|
|
91
|
+
}
|
|
92
|
+
/** Workspace registry entry used for project labels. */
|
|
93
|
+
export interface WorkspaceLike {
|
|
94
|
+
readonly id?: string;
|
|
95
|
+
readonly title?: string;
|
|
96
|
+
readonly path?: string;
|
|
97
|
+
}
|
|
98
|
+
export interface WorkspaceRegistryLike {
|
|
99
|
+
list(): readonly WorkspaceLike[];
|
|
100
|
+
}
|
|
101
|
+
/** The Host connection gate. */
|
|
102
|
+
export interface ConnectionLike {
|
|
103
|
+
requestRejection?(request: unknown): number | undefined;
|
|
104
|
+
}
|
|
105
|
+
/** The web server route registration face. */
|
|
106
|
+
export interface WebServerLike {
|
|
107
|
+
register(route: {
|
|
108
|
+
kind: 'exact' | 'prefix';
|
|
109
|
+
path: string;
|
|
110
|
+
handler: (req: HttpRequestLike, res: HttpResponseLike) => void | Promise<void>;
|
|
111
|
+
}): () => void;
|
|
112
|
+
}
|
|
113
|
+
/** Minimal HTTP request surface used by the route handler. */
|
|
114
|
+
export interface HttpRequestLike {
|
|
115
|
+
readonly method?: string;
|
|
116
|
+
readonly url?: string;
|
|
117
|
+
readonly headers: Record<string, string | string[] | undefined>;
|
|
118
|
+
}
|
|
119
|
+
/** Minimal HTTP response surface used by the route handler. */
|
|
120
|
+
export interface HttpResponseLike {
|
|
121
|
+
statusCode: number;
|
|
122
|
+
readonly headersSent?: boolean;
|
|
123
|
+
setHeader(name: string, value: string): void;
|
|
124
|
+
writeHead(status: number, headers?: Record<string, string>): unknown;
|
|
125
|
+
end(body?: string): void;
|
|
126
|
+
}
|
|
127
|
+
/** Services looked up at call time, so late mounts are not captured as absent. */
|
|
128
|
+
export interface HostServiceAccess {
|
|
129
|
+
persistence(): SessionPersistenceLike | undefined;
|
|
130
|
+
sessions(): SessionsLike | undefined;
|
|
131
|
+
sessionQuery(): SessionQueryLike | undefined;
|
|
132
|
+
workspaces(): WorkspaceRegistryLike | undefined;
|
|
133
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural views of the Host services this plugin consumes.
|
|
3
|
+
*
|
|
4
|
+
* They mirror the published DSH contracts the plugin was verified against
|
|
5
|
+
* (`ctx.sessionPersistence`, `ctx.sessions`, `ctx.sessionQuery`,
|
|
6
|
+
* `ctx.webServer`, `ctx.connection`, `ctx.workspaceRegistry`) without importing
|
|
7
|
+
* an implementation package. The installed Harness remains the authority.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic cross-check: fold result vs token-meter's official `tokenUsage`
|
|
3
|
+
* projection.
|
|
4
|
+
*
|
|
5
|
+
* `ctx.sessionQuery.observeSession(id, { projectionMode: 'all' })` exposes the
|
|
6
|
+
* registered projection values for a persisted session without publishing it.
|
|
7
|
+
* The check reports only ids and four-field buckets; no event content, title,
|
|
8
|
+
* or cwd is returned.
|
|
9
|
+
*/
|
|
10
|
+
import type { VerifyReport } from '../shared/types.js';
|
|
11
|
+
import type { TokenAggregator } from './aggregate.js';
|
|
12
|
+
import type { AggregatorSource } from './source.js';
|
|
13
|
+
/** Verify up to `limit` sessions (default 40, hard cap 200). */
|
|
14
|
+
export declare function verifyAgainstProjection(source: AggregatorSource, aggregator: TokenAggregator, limit?: number): Promise<VerifyReport>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic cross-check: fold result vs token-meter's official `tokenUsage`
|
|
3
|
+
* projection.
|
|
4
|
+
*
|
|
5
|
+
* `ctx.sessionQuery.observeSession(id, { projectionMode: 'all' })` exposes the
|
|
6
|
+
* registered projection values for a persisted session without publishing it.
|
|
7
|
+
* The check reports only ids and four-field buckets; no event content, title,
|
|
8
|
+
* or cwd is returned.
|
|
9
|
+
*/
|
|
10
|
+
import { bucketsEqual } from '../shared/model.js';
|
|
11
|
+
import { API_VERSION } from '../shared/types.js';
|
|
12
|
+
/** Verify up to `limit` sessions (default 40, hard cap 200). */
|
|
13
|
+
export async function verifyAgainstProjection(source, aggregator, limit = 40) {
|
|
14
|
+
const report = {
|
|
15
|
+
apiVersion: API_VERSION,
|
|
16
|
+
checked: 0,
|
|
17
|
+
matched: 0,
|
|
18
|
+
mismatched: 0,
|
|
19
|
+
unsupported: 0,
|
|
20
|
+
matchedBasis: { own: 0, allLog: 0, none: 0 },
|
|
21
|
+
details: [],
|
|
22
|
+
generatedAt: Date.now(),
|
|
23
|
+
};
|
|
24
|
+
if (source.projectUsage === undefined) {
|
|
25
|
+
report.unsupported = aggregator.sessionsForVerification().length;
|
|
26
|
+
return report;
|
|
27
|
+
}
|
|
28
|
+
const sessions = aggregator.sessionsForVerification().slice(0, clampLimit(limit));
|
|
29
|
+
const concurrency = 2;
|
|
30
|
+
let cursor = 0;
|
|
31
|
+
const worker = async () => {
|
|
32
|
+
for (;;) {
|
|
33
|
+
const session = sessions[cursor];
|
|
34
|
+
cursor += 1;
|
|
35
|
+
if (session === undefined)
|
|
36
|
+
return;
|
|
37
|
+
let official;
|
|
38
|
+
try {
|
|
39
|
+
official = await source.projectUsage?.(session.id);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
official = undefined;
|
|
43
|
+
}
|
|
44
|
+
report.checked += 1;
|
|
45
|
+
if (official === undefined) {
|
|
46
|
+
report.unsupported += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const match = bucketsEqual(official, session.own)
|
|
50
|
+
? 'own'
|
|
51
|
+
: bucketsEqual(official, session.allLog)
|
|
52
|
+
? 'allLog'
|
|
53
|
+
: 'none';
|
|
54
|
+
if (match === 'none') {
|
|
55
|
+
report.mismatched += 1;
|
|
56
|
+
report.matchedBasis.none += 1;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
report.matched += 1;
|
|
60
|
+
report.matchedBasis[match] += 1;
|
|
61
|
+
}
|
|
62
|
+
report.details.push({
|
|
63
|
+
sessionId: session.id,
|
|
64
|
+
official,
|
|
65
|
+
own: session.own,
|
|
66
|
+
allLog: session.allLog,
|
|
67
|
+
inheritedEventCount: session.inheritedEventCount,
|
|
68
|
+
match,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, sessions.length)) }, () => worker()));
|
|
73
|
+
return report;
|
|
74
|
+
}
|
|
75
|
+
function clampLimit(limit) {
|
|
76
|
+
if (!Number.isFinite(limit))
|
|
77
|
+
return 40;
|
|
78
|
+
return Math.min(200, Math.max(1, Math.trunc(limit)));
|
|
79
|
+
}
|