attesso 0.4.0 → 0.6.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 +201 -0
- package/NOTICE +4 -0
- package/README.md +172 -19
- package/bin/attesso-control-mcp.mjs +13 -0
- package/bin/attesso-mcp.mjs +2 -0
- package/bin/attesso.mjs +600 -65
- package/package.json +8 -4
- package/src/bundle.mjs +128 -0
- package/src/classify.mjs +83 -0
- package/src/cli-io.mjs +130 -0
- package/src/connect.mjs +160 -0
- package/src/control.mjs +101 -0
- package/src/decide.mjs +10 -0
- package/src/fs-paths.mjs +121 -0
- package/src/harness.mjs +130 -0
- package/src/hook.mjs +72 -37
- package/src/install.mjs +122 -0
- package/src/jsonl.mjs +16 -0
- package/src/ledger.mjs +79 -20
- package/src/mcp/control.mjs +171 -0
- package/src/mcp-proxy.mjs +605 -0
- package/src/merkle.mjs +83 -0
- package/src/policy.mjs +8 -8
- package/src/receipt.mjs +104 -0
- package/src/replay.mjs +133 -0
- package/src/report.mjs +69 -27
- package/src/rules.mjs +78 -6
- package/src/serve.mjs +4 -2
- package/src/setup.mjs +131 -0
- package/src/store.mjs +425 -64
- package/src/stream.mjs +72 -0
- package/src/toolpin.mjs +131 -0
- package/src/ui/catalog.mjs +266 -0
- package/src/ui/live.mjs +58 -0
- package/src/ui/query.mjs +163 -0
- package/src/ui/server.mjs +379 -0
- package/src/ui/static/index.html +1695 -0
- package/src/usage.mjs +96 -0
- package/src/watch.mjs +78 -0
package/src/toolpin.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// A tool manifest is small, local and hashable. Pin the definitions a harness
|
|
2
|
+
// reviewed so a changed description or schema is a refusal, not a quiet edit.
|
|
3
|
+
// This module never fetches a server's manifest; the caller supplies it.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { canonical } from './canonical.mjs';
|
|
6
|
+
|
|
7
|
+
const fail = (message) => {
|
|
8
|
+
throw new Error(`tool manifest is invalid: ${message}`);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function toolsDigest(tools) {
|
|
12
|
+
return createHash('sha256').update(canonical(tools)).digest('base64url');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function definitionOf(tool) {
|
|
16
|
+
const name = String(tool?.name ?? '').trim();
|
|
17
|
+
if (name === '') fail('every tool needs a name');
|
|
18
|
+
const definition = {
|
|
19
|
+
name,
|
|
20
|
+
description: typeof tool.description === 'string' ? tool.description : '',
|
|
21
|
+
inputSchema: tool.inputSchema ?? tool.input_schema ?? null,
|
|
22
|
+
};
|
|
23
|
+
// A tool can change what it returns without touching its input schema. Pin
|
|
24
|
+
// the output contract and annotations too, or a server can alter the effect
|
|
25
|
+
// while the lock still says the definition is unchanged.
|
|
26
|
+
if (tool.title !== undefined) definition.title = tool.title;
|
|
27
|
+
if (tool.outputSchema !== undefined) definition.outputSchema = tool.outputSchema;
|
|
28
|
+
if (tool.annotations !== undefined) definition.annotations = tool.annotations;
|
|
29
|
+
return definition;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function digestOf(value) {
|
|
33
|
+
return createHash('sha256').update(canonical(value)).digest('base64url');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function manifestDigest(manifest) {
|
|
37
|
+
return digestOf(manifest);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function definitionHash(tool) {
|
|
41
|
+
return createHash('sha256').update(canonical(definitionOf(tool))).digest('base64url');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function manifestTools(manifest) {
|
|
45
|
+
if (Array.isArray(manifest)) return manifest;
|
|
46
|
+
if (manifest && Array.isArray(manifest.tools)) return manifest.tools;
|
|
47
|
+
fail('expected a tools array');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function pinManifest(manifest, tools) {
|
|
51
|
+
const list = manifestTools(manifest);
|
|
52
|
+
const pins = {};
|
|
53
|
+
for (const tool of list) {
|
|
54
|
+
const definition = definitionOf(tool);
|
|
55
|
+
if (pins[definition.name] !== undefined) fail(`duplicate tool ${definition.name}`);
|
|
56
|
+
pins[definition.name] = definitionHash(tool);
|
|
57
|
+
}
|
|
58
|
+
const lock = { version: 1, tools_digest: toolsDigest(tools), tools: pins };
|
|
59
|
+
lock.manifest_digest = digestOf(manifest);
|
|
60
|
+
// The server handshake and prompts are model-visible too. Their digests are
|
|
61
|
+
// optional so a tools-only manifest keeps the original lock shape.
|
|
62
|
+
if (manifest.server !== undefined) lock.server_digest = digestOf(manifest.server);
|
|
63
|
+
if (manifest.prompts !== undefined) lock.prompts_digest = digestOf(manifest.prompts);
|
|
64
|
+
if (manifest.resources !== undefined) lock.resources_digest = digestOf(manifest.resources);
|
|
65
|
+
if (manifest.resourceTemplates !== undefined) lock.resource_templates_digest = digestOf(manifest.resourceTemplates);
|
|
66
|
+
return lock;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validateToolLock(lock) {
|
|
70
|
+
if (!lock || typeof lock !== 'object' || Array.isArray(lock)) fail('lock is not an object');
|
|
71
|
+
if (lock.version !== 1) fail('lock version must be 1');
|
|
72
|
+
if (typeof lock.tools_digest !== 'string' || lock.tools_digest === '') fail('lock has no tools digest');
|
|
73
|
+
if (!lock.tools || typeof lock.tools !== 'object' || Array.isArray(lock.tools)) fail('lock has no tool map');
|
|
74
|
+
for (const field of ['server_digest', 'prompts_digest', 'resources_digest', 'resource_templates_digest', 'manifest_digest']) {
|
|
75
|
+
if (lock[field] !== undefined && (typeof lock[field] !== 'string' || lock[field] === '')) {
|
|
76
|
+
fail(`lock has an invalid ${field}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const [name, hash] of Object.entries(lock.tools)) {
|
|
80
|
+
if (name === '' || typeof hash !== 'string' || hash === '') fail('lock has an invalid tool pin');
|
|
81
|
+
}
|
|
82
|
+
return lock;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function comparePin(lock, manifest, tools) {
|
|
86
|
+
validateToolLock(lock);
|
|
87
|
+
const expected = pinManifest(manifest, tools);
|
|
88
|
+
const names = new Set([...Object.keys(lock.tools), ...Object.keys(expected.tools)]);
|
|
89
|
+
const changed = [];
|
|
90
|
+
const added = [];
|
|
91
|
+
const removed = [];
|
|
92
|
+
for (const name of [...names].sort()) {
|
|
93
|
+
const before = lock.tools[name];
|
|
94
|
+
const after = expected.tools[name];
|
|
95
|
+
if (before === undefined) added.push(name);
|
|
96
|
+
else if (after === undefined) removed.push(name);
|
|
97
|
+
else if (before !== after) changed.push(name);
|
|
98
|
+
}
|
|
99
|
+
const surfaces = [];
|
|
100
|
+
for (const field of ['server', 'prompts', 'resources', 'resource_templates']) {
|
|
101
|
+
if (lock[`${field}_digest`] !== expected[`${field}_digest`]) surfaces.push(field);
|
|
102
|
+
}
|
|
103
|
+
if (lock.manifest_digest !== undefined && lock.manifest_digest !== expected.manifest_digest) surfaces.push('manifest');
|
|
104
|
+
const digestChanged = lock.tools_digest !== expected.tools_digest;
|
|
105
|
+
return {
|
|
106
|
+
ok: !digestChanged && changed.length === 0 && added.length === 0 && removed.length === 0 && surfaces.length === 0,
|
|
107
|
+
digestChanged,
|
|
108
|
+
changed,
|
|
109
|
+
added,
|
|
110
|
+
removed,
|
|
111
|
+
surfaces,
|
|
112
|
+
expected,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function toolGate(lock, currentToolsDigest, proposal) {
|
|
117
|
+
if (!lock) return null;
|
|
118
|
+
validateToolLock(lock);
|
|
119
|
+
if (lock.tools_digest !== currentToolsDigest) {
|
|
120
|
+
return { decision: 'DENY', code: 'TOOL_CHANGED', rule: 'tool.pin', tool_pin: 'changed' };
|
|
121
|
+
}
|
|
122
|
+
if (!proposal.tool_hash) return null;
|
|
123
|
+
const pinned = lock.tools[proposal.subject];
|
|
124
|
+
if (pinned === undefined) {
|
|
125
|
+
return { decision: 'DENY', code: 'TOOL_UNPINNED', rule: 'tool.pin', tool_pin: 'unpinned' };
|
|
126
|
+
}
|
|
127
|
+
if (pinned !== proposal.tool_hash) {
|
|
128
|
+
return { decision: 'DENY', code: 'TOOL_CHANGED', rule: 'tool.pin', tool_pin: 'changed' };
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// A disposable day/offset catalog over the signed ledger. The ledger remains
|
|
2
|
+
// the source of truth; this file only helps the local UI read bounded ranges.
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { isInitialised, paths, readConfig } from '../store.mjs';
|
|
6
|
+
import { forEachEntrySync } from '../stream.mjs';
|
|
7
|
+
|
|
8
|
+
const INDEX_VERSION = 1;
|
|
9
|
+
const CHECKPOINT_EVERY = 256;
|
|
10
|
+
const INDEX_FILE = 'ui-index.json';
|
|
11
|
+
|
|
12
|
+
const readJson = (file) => {
|
|
13
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
14
|
+
};
|
|
15
|
+
const statOf = (file) => {
|
|
16
|
+
try {
|
|
17
|
+
const stat = statSync(file);
|
|
18
|
+
return { dev: stat.dev, ino: stat.ino, size: stat.size, mtime_ms: stat.mtimeMs };
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const sameFile = (left, right) =>
|
|
24
|
+
left && right && left.dev === right.dev && left.ino === right.ino && left.size === right.size;
|
|
25
|
+
|
|
26
|
+
export function validateTimeZone(value) {
|
|
27
|
+
if (value === undefined || value === null || value === '') return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
28
|
+
try {
|
|
29
|
+
new Intl.DateTimeFormat('en-US', { timeZone: value }).format(0);
|
|
30
|
+
return value;
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(`unknown time zone: ${value}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const partsAt = (ms, timeZone) => {
|
|
37
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
38
|
+
timeZone,
|
|
39
|
+
hourCycle: 'h23',
|
|
40
|
+
year: 'numeric',
|
|
41
|
+
month: '2-digit',
|
|
42
|
+
day: '2-digit',
|
|
43
|
+
hour: '2-digit',
|
|
44
|
+
minute: '2-digit',
|
|
45
|
+
second: '2-digit',
|
|
46
|
+
}).formatToParts(new Date(ms));
|
|
47
|
+
const value = {};
|
|
48
|
+
for (const part of parts) if (part.type !== 'literal') value[part.type] = Number(part.value);
|
|
49
|
+
return value;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const offsetAt = (ms, timeZone) => {
|
|
53
|
+
const p = partsAt(ms, timeZone);
|
|
54
|
+
return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - ms;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function zonedMidnight(day, timeZone) {
|
|
58
|
+
const [year, month, date] = day.split('-').map(Number);
|
|
59
|
+
let guess = Date.UTC(year, month - 1, date);
|
|
60
|
+
// Two passes settle a DST boundary where the offset changes at midnight.
|
|
61
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
62
|
+
guess = Date.UTC(year, month - 1, date) - offsetAt(guess, timeZone);
|
|
63
|
+
}
|
|
64
|
+
return guess;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function dayFor(iso, timeZone) {
|
|
68
|
+
const p = partsAt(Date.parse(iso), timeZone);
|
|
69
|
+
return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const nextDay = (day) => {
|
|
73
|
+
const [year, month, date] = day.split('-').map(Number);
|
|
74
|
+
const value = new Date(Date.UTC(year, month - 1, date + 1));
|
|
75
|
+
return value.toISOString().slice(0, 10);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function dayBounds(day, timeZone) {
|
|
79
|
+
return { start: zonedMidnight(day, timeZone), end: zonedMidnight(nextDay(day), timeZone) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function checkpointAt(ms) {
|
|
83
|
+
return this.checkpoints.reduce((best, item) => (Date.parse(item.at) <= ms ? item : best), null);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function indexMatches(index, ledger, stat, head) {
|
|
87
|
+
return index
|
|
88
|
+
&& index.version === INDEX_VERSION
|
|
89
|
+
&& index.ledger
|
|
90
|
+
&& sameFile(index.ledger, stat)
|
|
91
|
+
&& index.head === head
|
|
92
|
+
&& Array.isArray(index.checkpoints);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class Catalog {
|
|
96
|
+
constructor(dir, { write = true } = {}) {
|
|
97
|
+
this.dir = dir;
|
|
98
|
+
this.file = paths(dir).ledger;
|
|
99
|
+
this.indexFile = paths(dir).uiIndex;
|
|
100
|
+
this.write = write;
|
|
101
|
+
this.checkpoints = [];
|
|
102
|
+
this.head = 0;
|
|
103
|
+
this.signature = null;
|
|
104
|
+
this.ready = false;
|
|
105
|
+
this.load();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
load() {
|
|
109
|
+
if (!isInitialised(this.dir)) return;
|
|
110
|
+
const stat = statOf(this.file);
|
|
111
|
+
if (!stat) return;
|
|
112
|
+
let head;
|
|
113
|
+
try { head = readConfig(this.dir).head.count; } catch { head = null; }
|
|
114
|
+
if (head === null) return;
|
|
115
|
+
const index = readJson(this.indexFile);
|
|
116
|
+
if (indexMatches(index, this.file, stat, head)) {
|
|
117
|
+
this.checkpoints = index.checkpoints;
|
|
118
|
+
this.head = index.head;
|
|
119
|
+
this.signature = stat;
|
|
120
|
+
this.ready = true;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.rebuild();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
rebuild() {
|
|
127
|
+
this.checkpoints = [];
|
|
128
|
+
this.head = 0;
|
|
129
|
+
let end = 0;
|
|
130
|
+
try {
|
|
131
|
+
forEachEntrySync(this.file, (entry, index, start, next) => {
|
|
132
|
+
if (index === 1 || index % CHECKPOINT_EVERY === 0) {
|
|
133
|
+
this.checkpoints.push({ seq: entry.seq, offset: start, at: entry.at });
|
|
134
|
+
}
|
|
135
|
+
this.head = entry.seq;
|
|
136
|
+
end = next;
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
// A corrupted ledger is reported by the query, not hidden behind an index.
|
|
140
|
+
this.ready = false;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const stat = statOf(this.file);
|
|
144
|
+
if (stat) {
|
|
145
|
+
this.signature = { ...stat, size: end || stat.size };
|
|
146
|
+
this.ready = true;
|
|
147
|
+
this.persist();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
persist() {
|
|
152
|
+
if (!this.write || !this.ready) return;
|
|
153
|
+
const value = {
|
|
154
|
+
version: INDEX_VERSION,
|
|
155
|
+
ledger: this.signature,
|
|
156
|
+
head: this.head,
|
|
157
|
+
checkpoints: this.checkpoints,
|
|
158
|
+
};
|
|
159
|
+
try {
|
|
160
|
+
mkdirSync(dirname(this.indexFile), { recursive: true });
|
|
161
|
+
const temp = `${this.indexFile}.${process.pid}.tmp`;
|
|
162
|
+
writeFileSync(temp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
163
|
+
renameSync(temp, this.indexFile);
|
|
164
|
+
} catch {
|
|
165
|
+
// A read-only ledger still works; the index stays in memory.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
refresh() {
|
|
170
|
+
if (!this.ready) {
|
|
171
|
+
this.load();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const stat = statOf(this.file);
|
|
175
|
+
if (!stat) {
|
|
176
|
+
this.ready = false;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (stat.size === this.signature.size && stat.ino === this.signature.ino) return;
|
|
180
|
+
// A live append can be recorded without rescanning. Anything else is a
|
|
181
|
+
// replacement or truncation and gets a full rebuild.
|
|
182
|
+
if (stat.size > this.signature.size && stat.ino === this.signature.ino) {
|
|
183
|
+
this.extendFrom(this.signature.size);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.rebuild();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
extendFrom(offset) {
|
|
190
|
+
try {
|
|
191
|
+
forEachEntrySync(this.file, (entry, index, start, next) => {
|
|
192
|
+
if (index === 1 || (this.head + 1) % CHECKPOINT_EVERY === 0) {
|
|
193
|
+
this.checkpoints.push({ seq: entry.seq, offset: start, at: entry.at });
|
|
194
|
+
}
|
|
195
|
+
this.head = entry.seq;
|
|
196
|
+
this.signature.size = next;
|
|
197
|
+
}, { startOffset: offset });
|
|
198
|
+
} catch {
|
|
199
|
+
this.ready = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
record(entry, start, end) {
|
|
204
|
+
if (!this.ready) return;
|
|
205
|
+
if ((this.head + 1) === entry.seq && (this.head % CHECKPOINT_EVERY === 0)) {
|
|
206
|
+
this.checkpoints.push({ seq: entry.seq, offset: start, at: entry.at });
|
|
207
|
+
}
|
|
208
|
+
this.head = entry.seq;
|
|
209
|
+
this.signature.size = end;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
startFor(ms) {
|
|
213
|
+
const checkpoint = checkpointAt.call(this, ms);
|
|
214
|
+
return checkpoint ? checkpoint.offset : 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
startForSeq(seq) {
|
|
218
|
+
let start = 0;
|
|
219
|
+
for (const checkpoint of this.checkpoints) {
|
|
220
|
+
if (checkpoint.seq <= seq) start = checkpoint.offset; else break;
|
|
221
|
+
}
|
|
222
|
+
return start;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
scanDay(day, timeZone, onEntry) {
|
|
226
|
+
if (!isInitialised(this.dir)) return;
|
|
227
|
+
this.refresh();
|
|
228
|
+
if (!this.ready) throw new Error('ledger is not readable');
|
|
229
|
+
const { start, end } = dayBounds(day, timeZone);
|
|
230
|
+
forEachEntrySync(this.file, (entry, index, startOffset, endOffset) => {
|
|
231
|
+
const at = Date.parse(entry.at);
|
|
232
|
+
if (at < start) return;
|
|
233
|
+
if (at >= end) return false;
|
|
234
|
+
if (dayFor(entry.at, timeZone) !== day) return;
|
|
235
|
+
onEntry(entry, startOffset, endOffset);
|
|
236
|
+
return undefined;
|
|
237
|
+
}, { startOffset: this.startFor(start) });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
scanAfter(seq, onEntry) {
|
|
241
|
+
if (!isInitialised(this.dir)) return;
|
|
242
|
+
this.refresh();
|
|
243
|
+
if (!this.ready) throw new Error('ledger is not readable');
|
|
244
|
+
let started = false;
|
|
245
|
+
forEachEntrySync(this.file, (entry, index, startOffset, endOffset) => {
|
|
246
|
+
if (!started && entry.seq <= seq) return;
|
|
247
|
+
started = true;
|
|
248
|
+
return onEntry(entry, startOffset, endOffset);
|
|
249
|
+
}, { startOffset: this.startForSeq(seq) });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
scanRecent(days, timeZone, onEntry) {
|
|
253
|
+
if (!isInitialised(this.dir)) return;
|
|
254
|
+
this.refresh();
|
|
255
|
+
if (!this.ready) throw new Error('ledger is not readable');
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
const start = now - days * 24 * 60 * 60 * 1000;
|
|
258
|
+
forEachEntrySync(this.file, (entry, index, startOffset, endOffset) => {
|
|
259
|
+
if (Date.parse(entry.at) < start) return;
|
|
260
|
+
onEntry(entry, startOffset, endOffset);
|
|
261
|
+
return undefined;
|
|
262
|
+
}, { startOffset: this.startFor(start) });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export { INDEX_FILE };
|
package/src/ui/live.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Byte-offset tail for the signed ledger. The UI server needs new entries, not
|
|
2
|
+
// a full re-read: a hook is a separate process, so the file is the boundary.
|
|
3
|
+
import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export class LedgerTail {
|
|
6
|
+
constructor(file, startOffset = 0) {
|
|
7
|
+
this.file = file;
|
|
8
|
+
this.fd = null;
|
|
9
|
+
this.offset = startOffset;
|
|
10
|
+
this.inode = null;
|
|
11
|
+
this.partial = '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
open() {
|
|
15
|
+
this.fd = openSync(this.file, 'r');
|
|
16
|
+
const stat = fstatSync(this.fd);
|
|
17
|
+
this.inode = stat.ino;
|
|
18
|
+
this.offset = stat.size;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
close() {
|
|
22
|
+
if (this.fd !== null) {
|
|
23
|
+
closeSync(this.fd);
|
|
24
|
+
this.fd = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Reads complete lines from the current offset. A partial final line is kept
|
|
29
|
+
// until the writer finishes it, so a reader never sees half an entry.
|
|
30
|
+
read(onLine) {
|
|
31
|
+
if (this.fd === null) return;
|
|
32
|
+
let stat;
|
|
33
|
+
try {
|
|
34
|
+
stat = fstatSync(this.fd);
|
|
35
|
+
} catch {
|
|
36
|
+
this.close();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (stat.ino !== this.inode || stat.size < this.offset) {
|
|
40
|
+
this.close();
|
|
41
|
+
this.open();
|
|
42
|
+
this.partial = '';
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (stat.size === this.offset) return;
|
|
46
|
+
const size = stat.size - this.offset;
|
|
47
|
+
const buffer = Buffer.alloc(size);
|
|
48
|
+
const bytes = readSync(this.fd, buffer, 0, size, this.offset);
|
|
49
|
+
this.offset += bytes;
|
|
50
|
+
this.partial += buffer.subarray(0, bytes).toString('utf8');
|
|
51
|
+
let newline;
|
|
52
|
+
while ((newline = this.partial.indexOf('\n')) !== -1) {
|
|
53
|
+
const line = this.partial.slice(0, newline);
|
|
54
|
+
this.partial = this.partial.slice(newline + 1);
|
|
55
|
+
if (line !== '') onLine(line);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/ui/query.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Query contracts over the catalog. The catalog owns byte ranges; this module
|
|
2
|
+
// owns the small JSON shapes the page consumes. Nothing here decides policy.
|
|
3
|
+
import { Catalog, dayFor, validateTimeZone } from './catalog.mjs';
|
|
4
|
+
|
|
5
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
6
|
+
const CURSOR = /^v1:(\d+):(.+)$/;
|
|
7
|
+
const isAction = (entry) => entry.action !== 'llm.session';
|
|
8
|
+
const matches = (row, filters) => {
|
|
9
|
+
if (filters.minute !== undefined) {
|
|
10
|
+
const [hour, minute] = row[1].split(':').map(Number);
|
|
11
|
+
if (Math.floor((hour * 60 + minute) / 5) * 5 !== filters.minute) return false;
|
|
12
|
+
}
|
|
13
|
+
if (filters.hour !== undefined && row[1].slice(0, 2) !== String(filters.hour).padStart(2, '0')) return false;
|
|
14
|
+
if (filters.tool && row[2] !== filters.tool) return false;
|
|
15
|
+
if (filters.capability && row[3] !== filters.capability) return false;
|
|
16
|
+
if (filters.decision && row[4] !== filters.decision) return false;
|
|
17
|
+
if (filters.search && !row.join(' ').toLowerCase().includes(filters.search)) return false;
|
|
18
|
+
return true;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
22
|
+
const timeParts = (iso, timeZone) => {
|
|
23
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
24
|
+
timeZone, hourCycle: 'h23', hour: '2-digit', minute: '2-digit',
|
|
25
|
+
}).formatToParts(new Date(iso));
|
|
26
|
+
const result = {};
|
|
27
|
+
for (const part of parts) if (part.type === 'hour' || part.type === 'minute') result[part.type] = Number(part.value);
|
|
28
|
+
return result;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function rowFor(entry, timeZone) {
|
|
32
|
+
const time = timeParts(entry.at, timeZone);
|
|
33
|
+
return [
|
|
34
|
+
entry.seq,
|
|
35
|
+
`${pad(time.hour)}:${pad(time.minute)}`,
|
|
36
|
+
entry.subject ?? 'unknown',
|
|
37
|
+
entry.capability ?? 'unknown',
|
|
38
|
+
entry.decision ?? 'UNKNOWN',
|
|
39
|
+
entry.code ?? '',
|
|
40
|
+
entry.task ?? 'unknown',
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseDay(value, timeZone) {
|
|
45
|
+
const day = value ?? dayFor(new Date().toISOString(), timeZone);
|
|
46
|
+
if (!DAY.test(day)) throw new Error(`invalid day: ${day}`);
|
|
47
|
+
// Reject impossible calendar dates such as 2026-02-31.
|
|
48
|
+
const [year, month, date] = day.split('-').map(Number);
|
|
49
|
+
const check = new Date(Date.UTC(year, month - 1, date));
|
|
50
|
+
if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== date) {
|
|
51
|
+
throw new Error(`invalid day: ${day}`);
|
|
52
|
+
}
|
|
53
|
+
return day;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseCursor(value, timeZone) {
|
|
57
|
+
if (value === undefined || value === null || value === '') return { seq: 0, timeZone };
|
|
58
|
+
const match = CURSOR.exec(String(value));
|
|
59
|
+
if (!match) throw new Error('invalid cursor');
|
|
60
|
+
const seq = Number(match[1]);
|
|
61
|
+
if (!Number.isSafeInteger(seq) || seq < 0) throw new Error('invalid cursor');
|
|
62
|
+
const cursorZone = validateTimeZone(match[2]);
|
|
63
|
+
if (cursorZone !== timeZone) throw new Error('cursor belongs to another time zone');
|
|
64
|
+
return { seq, timeZone };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const makeCursor = (seq, timeZone) => `v1:${seq}:${timeZone}`;
|
|
68
|
+
|
|
69
|
+
export function parseLimit(value, { fallback = 100, max = 200 } = {}) {
|
|
70
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
71
|
+
const number = Number(value);
|
|
72
|
+
if (!Number.isInteger(number) || number < 1 || number > max) {
|
|
73
|
+
throw new Error(`limit must be an integer from 1 to ${max}`);
|
|
74
|
+
}
|
|
75
|
+
return number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function summary(catalog, { day, timeZone }) {
|
|
79
|
+
let calls = 0;
|
|
80
|
+
let blocked = 0;
|
|
81
|
+
let first = null;
|
|
82
|
+
let last = null;
|
|
83
|
+
catalog.scanDay(day, timeZone, (entry) => {
|
|
84
|
+
if (!isAction(entry)) return;
|
|
85
|
+
calls += 1;
|
|
86
|
+
if (entry.decision === 'DENY') blocked += 1;
|
|
87
|
+
if (first === null) first = entry.seq;
|
|
88
|
+
last = entry.seq;
|
|
89
|
+
});
|
|
90
|
+
return { day, calls, blocked, allowed: calls - blocked, first_seq: first, last_seq: last, head: catalog.head, generated_at: new Date().toISOString() };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function flow(catalog, { day, timeZone, bucket = 5 }) {
|
|
94
|
+
if (!Number.isInteger(bucket) || bucket < 1 || bucket > 60) throw new Error('bucket must be between 1 and 60 minutes');
|
|
95
|
+
const buckets = new Map();
|
|
96
|
+
catalog.scanDay(day, timeZone, (entry) => {
|
|
97
|
+
if (!isAction(entry)) return;
|
|
98
|
+
const time = timeParts(entry.at, timeZone);
|
|
99
|
+
const minute = Math.floor((time.hour * 60 + time.minute) / bucket) * bucket;
|
|
100
|
+
const capability = entry.capability ?? 'unknown';
|
|
101
|
+
const key = `${minute}|${capability}`;
|
|
102
|
+
const item = buckets.get(key) ?? { minute, capability, total: 0, allowed: 0, blocked: 0, tools: new Set(), tasks: new Set(), codes: new Set() };
|
|
103
|
+
item.total += 1;
|
|
104
|
+
item.tools.add(entry.subject ?? 'unknown');
|
|
105
|
+
item.tasks.add(entry.task ?? 'unknown');
|
|
106
|
+
if (entry.decision === 'DENY') item.codes.add(entry.code ?? 'policy');
|
|
107
|
+
if (entry.decision === 'DENY') item.blocked += 1; else item.allowed += 1;
|
|
108
|
+
buckets.set(key, item);
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
day,
|
|
112
|
+
bucket,
|
|
113
|
+
buckets: [...buckets.values()]
|
|
114
|
+
.map((item) => ({ ...item, tools: [...item.tools].sort(), tasks: [...item.tasks].sort(), codes: [...item.codes].sort() }))
|
|
115
|
+
.sort((left, right) => left.minute - right.minute || left.capability.localeCompare(right.capability)),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function days(catalog, { timeZone, limit = 14 }) {
|
|
120
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 90) throw new Error('limit must be between 1 and 90');
|
|
121
|
+
const map = new Map();
|
|
122
|
+
catalog.scanRecent(limit + 1, timeZone, (entry) => {
|
|
123
|
+
if (!isAction(entry)) return;
|
|
124
|
+
const day = dayFor(entry.at, timeZone);
|
|
125
|
+
const item = map.get(day) ?? { day, calls: 0, blocked: 0 };
|
|
126
|
+
item.calls += 1;
|
|
127
|
+
if (entry.decision === 'DENY') item.blocked += 1;
|
|
128
|
+
map.set(day, item);
|
|
129
|
+
});
|
|
130
|
+
return [...map.values()].sort((left, right) => left.day.localeCompare(right.day)).slice(-limit);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function page(catalog, { day, timeZone, after = 0, limit = 100, filters = null }) {
|
|
134
|
+
const rows = [];
|
|
135
|
+
let hasMore = false;
|
|
136
|
+
const collect = (entry) => {
|
|
137
|
+
if (!isAction(entry)) return;
|
|
138
|
+
if (entry.seq <= after) return;
|
|
139
|
+
const row = rowFor(entry, timeZone);
|
|
140
|
+
if (filters && !matches(row, filters)) return;
|
|
141
|
+
if (rows.length >= limit) {
|
|
142
|
+
hasMore = true;
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
row[7] = dayFor(entry.at, timeZone);
|
|
146
|
+
rows.push(row);
|
|
147
|
+
return undefined;
|
|
148
|
+
};
|
|
149
|
+
if (day) {
|
|
150
|
+
catalog.scanDay(day, timeZone, collect);
|
|
151
|
+
} else {
|
|
152
|
+
catalog.scanAfter(after, collect);
|
|
153
|
+
}
|
|
154
|
+
const last = rows.length ? rows[rows.length - 1][0] : after;
|
|
155
|
+
return {
|
|
156
|
+
rows,
|
|
157
|
+
next_cursor: makeCursor(last, timeZone),
|
|
158
|
+
has_more: hasMore,
|
|
159
|
+
head: catalog.head,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export { Catalog };
|