deepdebug-local-agent 1.12.1 → 1.13.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/CHANGELOG.md +2 -0
- package/docs/la-chat.md +35 -0
- package/package.json +1 -1
- package/src/dlp/rules.js +2 -2
- package/src/la-protocol-v2/chat/config.js +60 -0
- package/src/la-protocol-v2/chat/cursors.js +57 -0
- package/src/la-protocol-v2/chat/dlp.js +92 -0
- package/src/la-protocol-v2/chat/index.js +28 -0
- package/src/la-protocol-v2/chat/store.js +337 -0
- package/src/la-protocol-v2/chat/stream-source.js +23 -0
- package/src/la-protocol-v2/content-handlers/chat-session.js +41 -0
- package/src/la-protocol-v2/content-handlers/handler.js +10 -2
- package/src/la-protocol-v2/content-handlers/install.js +2 -2
- package/src/la-protocol-v2/content-handlers/job-stream.js +62 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
- package/src/la-protocol-v2/contract/README.md +42 -0
- package/src/la-protocol-v2/contract/chat.session.body.schema.json +47 -0
- package/src/la-protocol-v2/contract/index.json +1 -1
- package/src/la-protocol-v2/contract/job.assign.schema.json +3 -2
- package/src/la-protocol-v2/contract/job.stream.body.schema.json +49 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +78 -51
- package/src/la-protocol-v2/jobs/agent2/config.js +1 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +92 -11
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +2 -0
- package/src/la-protocol-v2/jobs/manager.js +4 -2
- package/src/la-protocol-v2/runtime.js +32 -3
- package/test/contract/la-protocol-v2/vectors/chat.session.body.vectors.json +10379 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +245 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +112 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +568 -3
- package/test/contract/la-protocol-v2/vectors/job.stream.body.vectors.json +4572 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +106 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-chat-job-and-stream.json +311 -0
- package/test/la-protocol-v2/chat-dlp-json.test.js +169 -0
- package/test/la-protocol-v2/chat-la.test.js +490 -0
- package/test/la-protocol-v2/chat-real-e2e.test.js +69 -0
- package/test/la-protocol-v2/chat-store.test.js +301 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +6 -6
- package/test/la-protocol-v2/helpers/agent2-rig.js +2 -2
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +1 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +80 -5
- package/test/la-protocol-v2/traces.test.js +3 -3
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.js - the ChatStore (LA-3, ADR-0014 Design A): the conversation of a chat job lives HERE, on the customer's machine, and nowhere else.
|
|
3
|
+
*
|
|
4
|
+
* <dir>/<tenant>/<conversationId>.jsonl one file per conversation, mode 0600 in a 0700 folder, JSON Lines:
|
|
5
|
+
* {"k":"meta","v":1,"ws":"<workspaceId>","ts":"..."} first line
|
|
6
|
+
* {"k":"turn","role":"user|assistant","ts":"...","text":"..."} the person's prompt, the model's answer
|
|
7
|
+
* {"k":"ev","e":"<one event line of agent2>"} the live stream (position = order in the file), what `job.stream` replays
|
|
8
|
+
* {"k":"end","state":"done|failed|cancelled","ts":"..."} the job is over; no `end` in a file nobody is writing = interrupted (the agent restarted)
|
|
9
|
+
* Until the contract carries a conversation id, a chat job IS its own conversation: conversationId = jobId (agent2 does the same: its `summary` echoes the
|
|
10
|
+
* job id as `conversationId`). `chat.session` takes that id as `ref`.
|
|
11
|
+
*
|
|
12
|
+
* Rules:
|
|
13
|
+
* - NO SECRET IS EVER WRITTEN: every piece (prompt, event line, answer) goes through the DLP in enforce before it touches the disk (dlp.js); a piece the DLP
|
|
14
|
+
* cannot mask or blocks is stored as a fixed "withheld" text. The same pieces are masked again when they leave (content handlers).
|
|
15
|
+
* - The conversation leaves this process only through the content relay (`job.stream`, `chat.session`), never in a log, an audit, a stat, an error or a job frame.
|
|
16
|
+
* Everything this module logs or audits is a code, a counter or a short hash.
|
|
17
|
+
* - Retention (LOCAL_AGENT_CHAT_RETENTION_DAYS, default 30) and a size ceiling (LOCAL_AGENT_CHAT_MAX_BYTES) are enforced by a sweep at start and every hour; the
|
|
18
|
+
* oldest conversation goes first; a conversation being written is never swept. One conversation is capped (events beyond it are dropped, `truncated`).
|
|
19
|
+
* - File names are UUIDs checked here: no path ever comes from outside.
|
|
20
|
+
*/
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { short } from "../content-handlers/audit.js";
|
|
24
|
+
import { maskOrWithhold, maskEventLine, WITHHELD_LINE, WITHHELD_TEXT } from "./dlp.js";
|
|
25
|
+
|
|
26
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
27
|
+
const MAX_LINE_CHARS = 16_384; // contract: job.stream.body events[].e maxLength; agent2 cuts its lines at the same size
|
|
28
|
+
const MAX_TURN_CHARS = 65_536; // contract: chat.session.body turns[].text maxLength
|
|
29
|
+
const MAX_EVENTS = 5_000; // events kept for one conversation, whatever the byte cap says
|
|
30
|
+
const FILE_NAME = /^([0-9a-f-]{36})(\.\d+)?\.jsonl$/; // <id>.jsonl, or an archived run <id>.<epoch ms>.jsonl
|
|
31
|
+
const MAX_LOADED = 64; // conversations kept in memory (the ones being written are never evicted)
|
|
32
|
+
const utf8len = (s) => Buffer.byteLength(s, "utf8");
|
|
33
|
+
|
|
34
|
+
const isoNow = (now) => new Date(now()).toISOString();
|
|
35
|
+
|
|
36
|
+
export function createChatStore({ dir, tenantId, config, env = process.env, now = Date.now, log = console, audit = null, isActive = () => false }) {
|
|
37
|
+
const tenant = String(tenantId).toLowerCase();
|
|
38
|
+
if (!UUID.test(tenant)) throw new Error("chat store: the tenant id is not a UUID");
|
|
39
|
+
const tenantDir = path.join(dir, tenant);
|
|
40
|
+
const warn = (m) => (log.warn || log.log).call(log, m);
|
|
41
|
+
const note = (event, extra) => { try { audit?.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
|
|
42
|
+
const stats = { begun: 0, events: 0, eventsDropped: 0, withheld: 0, writeFailed: 0, loaded: 0, swept: 0, sweptBytes: 0, truncated: 0 };
|
|
43
|
+
const logs = new Map(); // conversationId -> ConversationLog
|
|
44
|
+
const createdWaiters = new Set(); // woken when a conversation is begun (a request that came before the job reached its runner)
|
|
45
|
+
let sweepTimer = null;
|
|
46
|
+
let closed = false;
|
|
47
|
+
|
|
48
|
+
fs.mkdirSync(tenantDir, { recursive: true, mode: 0o700 });
|
|
49
|
+
for (const d of [dir, tenantDir]) { try { fs.chmodSync(d, 0o700); } catch { /* not ours to change (a shared mount): the files are 0600 anyway */ } }
|
|
50
|
+
|
|
51
|
+
const fileOf = (id) => path.join(tenantDir, `${id}.jsonl`);
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------------------------------------------- a conversation in memory
|
|
54
|
+
function makeLog(id, workspaceId, startedAt) {
|
|
55
|
+
const l = { id, workspaceId, startedAt, turns: [], events: [], bytes: 0, truncated: false, ended: false, endState: null, writing: false,
|
|
56
|
+
waiters: new Set(), chain: Promise.resolve(), tokens: [], answer: "", tokenChars: 0 };
|
|
57
|
+
return l;
|
|
58
|
+
}
|
|
59
|
+
function wake(set) { for (const w of [...set]) { try { w(); } catch { /* a waiter's bug is its own */ } } }
|
|
60
|
+
|
|
61
|
+
/** Appends one record to the file, in order, without ever blocking the caller. A write error is counted and audited (a code), never thrown. */
|
|
62
|
+
function persist(l, obj) {
|
|
63
|
+
const line = `${JSON.stringify(obj)}\n`;
|
|
64
|
+
l.chain = l.chain.then(() => fs.promises.appendFile(fileOf(l.id), line, { mode: 0o600 })).catch(() => {
|
|
65
|
+
stats.writeFailed++;
|
|
66
|
+
note("chat_write_failed", { convShort: short(l.id) });
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function evict() {
|
|
71
|
+
if (logs.size <= MAX_LOADED) return;
|
|
72
|
+
for (const [id, l] of logs) {
|
|
73
|
+
if (logs.size <= MAX_LOADED) break;
|
|
74
|
+
if (!l.writing && l.waiters.size === 0) logs.delete(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Reads a conversation from its file (a torn last line is ignored). null = no such file / not a conversation of this tenant. */
|
|
79
|
+
function load(id) {
|
|
80
|
+
if (!UUID.test(id)) return null;
|
|
81
|
+
let text;
|
|
82
|
+
try {
|
|
83
|
+
const st = fs.lstatSync(fileOf(id));
|
|
84
|
+
if (!st.isFile() || st.size > Math.max(config.maxConversationBytes * 2, 1 << 20)) return null; // never a symlink, never an oversized file
|
|
85
|
+
text = fs.readFileSync(fileOf(id), "utf8");
|
|
86
|
+
} catch { return null; }
|
|
87
|
+
let l = null;
|
|
88
|
+
for (const raw of text.split("\n")) {
|
|
89
|
+
if (!raw) continue;
|
|
90
|
+
let r;
|
|
91
|
+
try { r = JSON.parse(raw); } catch { continue; }
|
|
92
|
+
if (!r || typeof r !== "object") continue;
|
|
93
|
+
if (r.k === "meta" && !l && typeof r.ws === "string") l = makeLog(id, r.ws, typeof r.ts === "string" ? r.ts : isoNow(now));
|
|
94
|
+
else if (!l) continue;
|
|
95
|
+
else if (r.k === "turn" && (r.role === "user" || r.role === "assistant") && typeof r.ts === "string" && typeof r.text === "string") l.turns.push({ role: r.role, ts: r.ts, text: r.text });
|
|
96
|
+
else if (r.k === "ev" && typeof r.e === "string") { l.events.push(r.e); l.bytes += utf8len(r.e); }
|
|
97
|
+
else if (r.k === "trunc") l.truncated = true;
|
|
98
|
+
else if (r.k === "end") { l.ended = true; l.endState = typeof r.state === "string" ? r.state : "done"; }
|
|
99
|
+
}
|
|
100
|
+
if (!l) return null;
|
|
101
|
+
if (!l.ended) { l.ended = true; l.endState = "interrupted"; } // nobody is writing it in this process
|
|
102
|
+
stats.loaded++;
|
|
103
|
+
return l;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @returns {object|null} the conversation (memory first, then disk) */
|
|
107
|
+
function get(id) {
|
|
108
|
+
if (typeof id !== "string" || !UUID.test(id)) return null;
|
|
109
|
+
let l = logs.get(id);
|
|
110
|
+
if (!l) { l = load(id); if (l) { logs.set(id, l); evict(); } }
|
|
111
|
+
return l || null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------------------------------------------- writing
|
|
115
|
+
/** Moves <id>.jsonl aside when it has its `end` mark (a torn file without one is an interrupted run and is replaced, as before). */
|
|
116
|
+
function archiveIfFinished(id) {
|
|
117
|
+
const file = fileOf(id);
|
|
118
|
+
let text;
|
|
119
|
+
try {
|
|
120
|
+
const st = fs.lstatSync(file);
|
|
121
|
+
if (!st.isFile()) return;
|
|
122
|
+
text = fs.readFileSync(file, "utf8");
|
|
123
|
+
} catch { return; } // no file: nothing to keep
|
|
124
|
+
if (!text.includes('\n{"k":"end"')) return;
|
|
125
|
+
let to = path.join(tenantDir, `${id}.${now()}.jsonl`);
|
|
126
|
+
for (let n = 1; fs.existsSync(to); n++) to = path.join(tenantDir, `${id}.${now()}${n}.jsonl`);
|
|
127
|
+
try { fs.renameSync(file, to); } catch { stats.writeFailed++; throw new Error("chat store: cannot archive the finished conversation"); }
|
|
128
|
+
note("chat_archived", { convShort: short(id) });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function begin({ jobId, workspaceId, userText }) {
|
|
132
|
+
if (closed || !UUID.test(String(jobId)) || typeof workspaceId !== "string" || !workspaceId) throw new Error("chat store: bad conversation");
|
|
133
|
+
const id = String(jobId);
|
|
134
|
+
const prev = logs.get(id);
|
|
135
|
+
if (prev && prev.writing) throw new Error("chat store: conversation already being written");
|
|
136
|
+
const l = makeLog(id, workspaceId, isoNow(now));
|
|
137
|
+
l.writing = true;
|
|
138
|
+
logs.set(id, l);
|
|
139
|
+
stats.begun++;
|
|
140
|
+
// a job id seen again (the agent restarted and the job was assigned again) starts a clean file: the old run's events would be a different run's.
|
|
141
|
+
// A file that already carries its `end` mark is a FINISHED conversation: it is archived (<id>.<epoch ms>.jsonl, same retention) and never truncated.
|
|
142
|
+
archiveIfFinished(id);
|
|
143
|
+
try { fs.writeFileSync(fileOf(id), "", { mode: 0o600 }); try { fs.chmodSync(fileOf(id), 0o600); } catch { /* the mode of the create is enough */ } } catch { stats.writeFailed++; l.writing = false; logs.delete(id); throw new Error("chat store: cannot create the conversation"); }
|
|
144
|
+
persist(l, { k: "meta", v: 1, ws: workspaceId, ts: l.startedAt });
|
|
145
|
+
if (typeof userText === "string" && userText.trim()) {
|
|
146
|
+
const m = maskOrWithhold(userText.trim().slice(0, MAX_TURN_CHARS), WITHHELD_TEXT, { env, source: "chat.store.user" });
|
|
147
|
+
if (m.withheld) stats.withheld++;
|
|
148
|
+
const turn = { role: "user", ts: isoNow(now), text: m.text };
|
|
149
|
+
l.turns.push(turn);
|
|
150
|
+
persist(l, { k: "turn", ...turn });
|
|
151
|
+
}
|
|
152
|
+
wake(createdWaiters);
|
|
153
|
+
return recorder(l);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function recorder(l) {
|
|
157
|
+
return {
|
|
158
|
+
conversationId: l.id,
|
|
159
|
+
/** One event line of agent2's content stream (a JSON string). Masked, stored, and handed to whoever is polling. */
|
|
160
|
+
append(line) {
|
|
161
|
+
if (l.ended || typeof line !== "string" || line.length === 0) return false;
|
|
162
|
+
const raw = line.length > MAX_LINE_CHARS ? line.slice(0, MAX_LINE_CHARS) : line;
|
|
163
|
+
// what the answer is made of (before masking: a mask could break the JSON this reads; the answer is masked as a whole at the end)
|
|
164
|
+
try {
|
|
165
|
+
const o = JSON.parse(raw);
|
|
166
|
+
if (o && o.type === "token" && typeof o.content === "string" && l.tokenChars < MAX_TURN_CHARS) { l.tokens.push(o.content); l.tokenChars += o.content.length; }
|
|
167
|
+
else if (o && o.type === "summary" && o.content && typeof o.content.answer === "string" && o.content.answer.trim()) l.answer = o.content.answer;
|
|
168
|
+
} catch { /* not JSON: kept as an event, not part of the answer */ }
|
|
169
|
+
const m = maskEventLine(raw, WITHHELD_LINE, { env, source: "chat.store.event" });
|
|
170
|
+
const e = m.text.length > MAX_LINE_CHARS ? WITHHELD_LINE : m.text; // a mask that grew the line past the contract's limit: withheld, never cut (a cut line is not JSON)
|
|
171
|
+
if (m.withheld || e === WITHHELD_LINE) stats.withheld++;
|
|
172
|
+
const bytes = utf8len(e);
|
|
173
|
+
if (l.events.length >= MAX_EVENTS || l.bytes + bytes > config.maxConversationBytes) {
|
|
174
|
+
if (!l.truncated) { l.truncated = true; stats.truncated++; persist(l, { k: "trunc", ts: isoNow(now) }); }
|
|
175
|
+
stats.eventsDropped++;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
l.events.push(e);
|
|
179
|
+
l.bytes += bytes;
|
|
180
|
+
stats.events++;
|
|
181
|
+
persist(l, { k: "ev", e });
|
|
182
|
+
wake(l.waiters);
|
|
183
|
+
return true;
|
|
184
|
+
},
|
|
185
|
+
/** The job is over. `state`: done | failed | cancelled. Writes the assistant turn (masked) and the end mark; everyone polling is woken. Idempotent. */
|
|
186
|
+
finish(state) {
|
|
187
|
+
if (l.ended) return;
|
|
188
|
+
const st = ["done", "failed", "cancelled"].includes(state) ? state : "failed";
|
|
189
|
+
const text = (l.answer || l.tokens.join("\n\n")).trim();
|
|
190
|
+
if (text) {
|
|
191
|
+
const m = maskOrWithhold(text.slice(0, MAX_TURN_CHARS), WITHHELD_TEXT, { env, source: "chat.store.answer" });
|
|
192
|
+
if (m.withheld) stats.withheld++;
|
|
193
|
+
const turn = { role: "assistant", ts: isoNow(now), text: m.text.slice(0, MAX_TURN_CHARS) };
|
|
194
|
+
l.turns.push(turn);
|
|
195
|
+
persist(l, { k: "turn", ...turn });
|
|
196
|
+
}
|
|
197
|
+
l.ended = true;
|
|
198
|
+
l.endState = st;
|
|
199
|
+
l.writing = false;
|
|
200
|
+
l.tokens = []; l.answer = "";
|
|
201
|
+
persist(l, { k: "end", state: st, ts: isoNow(now) });
|
|
202
|
+
wake(l.waiters);
|
|
203
|
+
evict();
|
|
204
|
+
},
|
|
205
|
+
get ended() { return l.ended; },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------------------------------------------- reading
|
|
210
|
+
/** Waits until `wakeSet` fires, `ms` pass or `signal` aborts. Resolves in every case (the caller looks at the state again). */
|
|
211
|
+
function waitOn(set, ms, signal) {
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
if (signal?.aborted || ms <= 0) { resolve(); return; }
|
|
214
|
+
let timer = null;
|
|
215
|
+
const done = () => { clearTimeout(timer); set.delete(done); signal?.removeEventListener("abort", done); resolve(); };
|
|
216
|
+
timer = setTimeout(done, ms); // bounded by the hold of the poll (<= 8 s) and cleared by every way out: it does not need to be unref'd
|
|
217
|
+
set.add(done);
|
|
218
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* One page of the stream of a conversation, from event `from`, holding the request up to `waitMs` for something new (long-poll).
|
|
224
|
+
* @returns {Promise<{status:"not_found"|"aborted"} | {status:"ok", events:{i:number,e:string}[], next:number, done:boolean, truncated:boolean}>}
|
|
225
|
+
*/
|
|
226
|
+
async function readStream({ conversationId, workspaceId, from = 0, waitMs = config.pollMs, signal }) {
|
|
227
|
+
const deadline = now() + Math.max(0, waitMs);
|
|
228
|
+
let l = get(conversationId);
|
|
229
|
+
// the job was accepted but has not reached its runner yet (workspace being cloned, agent2 starting): wait for it, as long as the job is alive
|
|
230
|
+
while (!l && !closed && !signal?.aborted && isActive(conversationId) && now() < deadline) {
|
|
231
|
+
await waitOn(createdWaiters, Math.min(250, deadline - now()), signal);
|
|
232
|
+
l = get(conversationId);
|
|
233
|
+
}
|
|
234
|
+
if (signal?.aborted) return { status: "aborted" };
|
|
235
|
+
if (!l || l.workspaceId !== workspaceId) return { status: "not_found" };
|
|
236
|
+
if (!Number.isSafeInteger(from) || from < 0 || from > l.events.length) return { status: "not_found" };
|
|
237
|
+
while (l.events.length <= from && !l.ended && !closed && !signal?.aborted && now() < deadline) await waitOn(l.waiters, deadline - now(), signal);
|
|
238
|
+
if (signal?.aborted) return { status: "aborted" };
|
|
239
|
+
const events = [];
|
|
240
|
+
let bytes = 0;
|
|
241
|
+
let i = from;
|
|
242
|
+
while (i < l.events.length && events.length < config.pageEvents) {
|
|
243
|
+
const e = l.events[i];
|
|
244
|
+
const b = utf8len(e);
|
|
245
|
+
if (events.length > 0 && bytes + b > config.pageBytes) break;
|
|
246
|
+
events.push({ i, e });
|
|
247
|
+
bytes += b;
|
|
248
|
+
i++;
|
|
249
|
+
}
|
|
250
|
+
return { status: "ok", events, next: i, done: l.ended && i >= l.events.length, truncated: l.truncated };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The turns of a conversation (copies). */
|
|
254
|
+
function readSession({ conversationId, workspaceId }) {
|
|
255
|
+
const l = get(conversationId);
|
|
256
|
+
if (!l || l.workspaceId !== workspaceId) return { status: "not_found" };
|
|
257
|
+
return { status: "ok", turns: l.turns.map((t) => ({ ...t })), truncated: l.truncated };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------------------------------------------- retention
|
|
261
|
+
/** Deletes the conversations older than the retention, then the oldest ones while the store is above its ceiling. Never one being written. */
|
|
262
|
+
async function sweep() {
|
|
263
|
+
let names;
|
|
264
|
+
try { names = await fs.promises.readdir(tenantDir); } catch { return { removed: 0, bytes: 0 }; }
|
|
265
|
+
const files = [];
|
|
266
|
+
for (const n of names) {
|
|
267
|
+
const m = FILE_NAME.exec(n);
|
|
268
|
+
if (!m || !UUID.test(m[1])) continue;
|
|
269
|
+
const mem = logs.get(m[1]);
|
|
270
|
+
if (!m[2] && mem && mem.writing) continue; // an archive (m[2]) is never the file being written
|
|
271
|
+
try { const st = await fs.promises.lstat(path.join(tenantDir, n)); if (st.isFile()) files.push({ id: m[1], archived: !!m[2], file: path.join(tenantDir, n), size: st.size, mtime: st.mtimeMs }); } catch { /* gone */ }
|
|
272
|
+
}
|
|
273
|
+
files.sort((a, b) => a.mtime - b.mtime);
|
|
274
|
+
let total = files.reduce((s, f) => s + f.size, 0);
|
|
275
|
+
let removed = 0;
|
|
276
|
+
let bytes = 0;
|
|
277
|
+
const drop = async (f) => {
|
|
278
|
+
try { await fs.promises.unlink(f.file); } catch { return; }
|
|
279
|
+
if (!f.archived) logs.delete(f.id);
|
|
280
|
+
total -= f.size; removed++; bytes += f.size;
|
|
281
|
+
};
|
|
282
|
+
const cutoff = now() - config.retentionMs;
|
|
283
|
+
const keep = [];
|
|
284
|
+
for (const f of files) { if (f.mtime < cutoff) await drop(f); else keep.push(f); }
|
|
285
|
+
for (const f of keep) { if (total <= config.maxBytes) break; await drop(f); }
|
|
286
|
+
stats.swept += removed; stats.sweptBytes += bytes;
|
|
287
|
+
if (removed) note("chat_swept", { removed, bytes });
|
|
288
|
+
return { removed, bytes };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Removes the conversations (live and archived) of one workspace. NOTE: nothing calls this yet (the Local Agent has no "workspace deleted" event to hang it on);
|
|
293
|
+
* it is kept, tested, for the day the materializer reports a removal. Until then only the retention sweep deletes conversations.
|
|
294
|
+
*/
|
|
295
|
+
async function deleteWorkspace(workspaceId) {
|
|
296
|
+
let removed = 0;
|
|
297
|
+
let names = [];
|
|
298
|
+
try { names = await fs.promises.readdir(tenantDir); } catch { return 0; }
|
|
299
|
+
for (const n of names) {
|
|
300
|
+
const m = FILE_NAME.exec(n);
|
|
301
|
+
if (!m || !UUID.test(m[1])) continue;
|
|
302
|
+
if (m[2]) { // an archived run: its workspace is in its first line
|
|
303
|
+
let ws = null;
|
|
304
|
+
try {
|
|
305
|
+
const fd = await fs.promises.open(path.join(tenantDir, n), "r");
|
|
306
|
+
try { const b = Buffer.alloc(1024); const { bytesRead } = await fd.read(b, 0, 1024, 0); ws = JSON.parse(b.toString("utf8", 0, bytesRead).split("\n")[0]).ws; } finally { await fd.close(); }
|
|
307
|
+
} catch { continue; }
|
|
308
|
+
if (ws !== workspaceId) continue;
|
|
309
|
+
try { await fs.promises.unlink(path.join(tenantDir, n)); removed++; } catch { /* gone */ }
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const l = get(m[1]);
|
|
313
|
+
if (!l || l.workspaceId !== workspaceId || l.writing) continue;
|
|
314
|
+
try { await fs.promises.unlink(path.join(tenantDir, n)); logs.delete(m[1]); removed++; } catch { /* gone */ }
|
|
315
|
+
}
|
|
316
|
+
return removed;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
sweep().catch(() => {});
|
|
320
|
+
sweepTimer = setInterval(() => { sweep().catch(() => {}); }, config.sweepMs);
|
|
321
|
+
if (sweepTimer.unref) sweepTimer.unref();
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
stats, dir: tenantDir,
|
|
325
|
+
begin, readStream, readSession, sweep, deleteWorkspace,
|
|
326
|
+
/** For tests and the health of the agent: how many conversations are open in memory. */
|
|
327
|
+
get open() { return logs.size; },
|
|
328
|
+
/** Resolves when every write queued so far reached the disk. */
|
|
329
|
+
async flush() { await Promise.all([...logs.values()].map((l) => l.chain)); },
|
|
330
|
+
close() {
|
|
331
|
+
closed = true;
|
|
332
|
+
clearInterval(sweepTimer);
|
|
333
|
+
for (const l of logs.values()) wake(l.waiters);
|
|
334
|
+
wake(createdWaiters);
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stream-source.js - feeds the ChatStore from agent2's content stream (`GET /local/v1/jobs/{id}/stream`, LOCAL ONLY, LA-3).
|
|
3
|
+
*
|
|
4
|
+
* agent2 replays that stream from its start at every connection and ends it when the job is over, so a reconnect skips the lines already handled (counted).
|
|
5
|
+
* Returns when the stream ended on its own, when the signal aborted, or after `reconnects` reconnections that did not complete it. It never throws: a stream that
|
|
6
|
+
* cannot be read leaves the conversation with what arrived (the job's result is the truth about how it ended). The lines are content: they go to `recorder.append`
|
|
7
|
+
* (masked, stored) and nowhere else.
|
|
8
|
+
*/
|
|
9
|
+
export async function consumeAgent2Stream({ api, jobId, recorder, signal, reconnects = 3, sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref(); }), isDown = () => false }) {
|
|
10
|
+
let seen = 0;
|
|
11
|
+
for (let attempt = 0; ; attempt++) {
|
|
12
|
+
let count = 0;
|
|
13
|
+
try {
|
|
14
|
+
const r = await api.stream(jobId, { signal, onLine: (line) => { count++; if (count > seen) { seen = count; recorder.append(line); } } });
|
|
15
|
+
if (r && r.ended) return { ended: true, lines: seen };
|
|
16
|
+
} catch (e) {
|
|
17
|
+
if (signal.aborted) return { ended: false, lines: seen };
|
|
18
|
+
if (e && (e.code === "not_found" || e.code === "unauthorized")) return { ended: false, lines: seen };
|
|
19
|
+
}
|
|
20
|
+
if (signal.aborted || isDown() || attempt >= reconnects) return { ended: false, lines: seen };
|
|
21
|
+
await sleep(250 * (attempt + 1));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chat-session.js - `content.request kind=chat.session` (contract 2.3, LA-3): the stored conversation, read from the ChatStore of THIS machine when a person
|
|
3
|
+
* reopens a chat. ref = the conversation UUID (= the id of the chat job that started it). Body: {turns:[{role, ts, text}], truncated} (schema
|
|
4
|
+
* chat.session.body.schema.json). Every text is masked on its own (DLP enforce); when the whole does not fit one content.response the OLDEST turns are left out
|
|
5
|
+
* and `truncated` is true. Unknown id or another workspace than the conversation's: `not_found`.
|
|
6
|
+
*/
|
|
7
|
+
import { maskOrWithhold, WITHHELD_TEXT } from "../chat/dlp.js";
|
|
8
|
+
|
|
9
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
10
|
+
const MAX_TURNS = 1000;
|
|
11
|
+
const MAX_TURN_CHARS = 65_536;
|
|
12
|
+
const OVERHEAD = 64;
|
|
13
|
+
|
|
14
|
+
export function produceChatSession({ store, env, maxBytes }, ctx, p) {
|
|
15
|
+
if (p.range) return { status: "error", errorCode: "protocol_unsupported" };
|
|
16
|
+
if (typeof p.ref !== "string" || !UUID.test(p.ref)) return { status: "not_found" };
|
|
17
|
+
const r = store.readSession({ conversationId: p.ref, workspaceId: ctx.workspaceId });
|
|
18
|
+
if (r.status !== "ok") return { status: "not_found" };
|
|
19
|
+
let total = 0;
|
|
20
|
+
const redactions = {};
|
|
21
|
+
const masked = r.turns.map((t) => {
|
|
22
|
+
const m = maskOrWithhold(t.text, WITHHELD_TEXT, { env, source: "content.chat.session" });
|
|
23
|
+
total += m.total;
|
|
24
|
+
for (const [k, n] of Object.entries(m.redactions || {})) redactions[k] = (redactions[k] || 0) + n;
|
|
25
|
+
return { role: t.role, ts: t.ts, text: m.text.slice(0, MAX_TURN_CHARS) };
|
|
26
|
+
});
|
|
27
|
+
// keep the NEWEST turns that fit: walk from the end
|
|
28
|
+
let size = OVERHEAD;
|
|
29
|
+
let first = masked.length;
|
|
30
|
+
for (let i = masked.length - 1; i >= 0; i--) {
|
|
31
|
+
const add = JSON.stringify(masked[i]).length + 1;
|
|
32
|
+
if (masked.length - i > MAX_TURNS || size + add > maxBytes) break;
|
|
33
|
+
size += add;
|
|
34
|
+
first = i;
|
|
35
|
+
}
|
|
36
|
+
const turns = masked.slice(first);
|
|
37
|
+
const truncated = r.truncated || first > 0;
|
|
38
|
+
const buffer = Buffer.from(JSON.stringify({ turns, truncated }), "utf8");
|
|
39
|
+
if (buffer.length > maxBytes) return { status: "error", errorCode: "payload_too_large" };
|
|
40
|
+
return { status: "ok", buffer, contentType: "application/json", dlp: { mode: "enforce", total, redactions } };
|
|
41
|
+
}
|
|
@@ -26,13 +26,18 @@ import { listFolder, ROOT_REF, clampLimit, MAX_LIST_BYTES } from "./file-list.js
|
|
|
26
26
|
import { worktreeDiff } from "./git-diff.js";
|
|
27
27
|
import { prepareOutgoing, prepareList, maskName, contentTypeOf, decodeText } from "./egress.js";
|
|
28
28
|
import { createAudit, short } from "./audit.js";
|
|
29
|
+
import { produceJobStream } from "./job-stream.js";
|
|
30
|
+
import { produceChatSession } from "./chat-session.js";
|
|
29
31
|
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
30
32
|
|
|
31
33
|
export const SERVED_KINDS = Object.freeze(["file", "diff", "file.list"]);
|
|
34
|
+
/** Contract 2.3 (LA-3): served only when the chat is on (`chat` dependency of createContentHandler); they read the ChatStore, not the work tree. */
|
|
35
|
+
export const CHAT_KINDS = Object.freeze(["job.stream", "chat.session"]);
|
|
32
36
|
/** Symbolic ref of kind `diff`: the whole work tree of the envelope's workspace (carries no client data). */
|
|
33
37
|
export const WORKTREE_REF = "worktree";
|
|
34
38
|
|
|
35
|
-
export function createContentHandler({ session, tenantId, resolveWorkspace, registry, config, env = process.env, log = console, audit = null, now = Date.now, mint = null }) {
|
|
39
|
+
export function createContentHandler({ session, tenantId, resolveWorkspace, registry, config, env = process.env, log = console, audit = null, now = Date.now, mint = null, chat = null }) {
|
|
40
|
+
const served = new Set(chat ? [...SERVED_KINDS, ...CHAT_KINDS] : SERVED_KINDS);
|
|
36
41
|
// F3-07c: where the ids of a listing come from. install.js passes mintRef (registry + content.index); without it (tests) the registry alone.
|
|
37
42
|
const mintFor = (workspaceId, root) => (o) => (mint ? mint({ workspaceId, ...o }) : registry.mint({ workspaceId, root, ...o }));
|
|
38
43
|
const inflight = new Map(); // requestId -> { ac, timer }
|
|
@@ -46,10 +51,13 @@ export function createContentHandler({ session, tenantId, resolveWorkspace, regi
|
|
|
46
51
|
|
|
47
52
|
/** @returns {Promise<{status:string, errorCode?:string, buffer?:Buffer, contentType?:string, dlp?:object}>} */
|
|
48
53
|
async function produce(ctx, p, signal) {
|
|
49
|
-
if (!
|
|
54
|
+
if (!served.has(p.kind)) return { status: "error", errorCode: "protocol_unsupported" };
|
|
50
55
|
// F3-07b: content never leaves unmasked. runtime.js does not even register this handler without DLP enforce; this is the
|
|
51
56
|
// same rule at the moment of the request (the env is read per request), so a changed mode can not open the door.
|
|
52
57
|
if (dlpConfigFromEnv(env).mode !== "enforce") return { status: "error", errorCode: "protocol_unsupported" };
|
|
58
|
+
// LA-3: the conversation is in the ChatStore, bound to the workspace id of its job (no root needed: it must stay readable after a restart)
|
|
59
|
+
if (p.kind === "job.stream") return produceJobStream({ ...chat, env, maxBytes: config.maxBytes }, ctx, p, signal);
|
|
60
|
+
if (p.kind === "chat.session") return produceChatSession({ ...chat, env, maxBytes: config.maxBytes }, ctx, p);
|
|
53
61
|
const ws = await resolveWorkspace(ctx.workspaceId);
|
|
54
62
|
if (!ws || !ws.root) return { status: "not_found" };
|
|
55
63
|
if (p.kind === "file.list") return produceList(ctx, p, ws, signal);
|
|
@@ -17,7 +17,7 @@ import { decodeText } from "./egress.js";
|
|
|
17
17
|
|
|
18
18
|
export const CONTENT_CAPABILITIES = Object.freeze(SERVED_KINDS.map((k) => `content.${k}`));
|
|
19
19
|
|
|
20
|
-
export function installContentHandlers({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console }) {
|
|
20
|
+
export function installContentHandlers({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console, chat = null }) {
|
|
21
21
|
// F3-07b, defence in depth: runtime.js already refuses; nobody can install the handlers without DLP enforce
|
|
22
22
|
if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("content handlers need LOCAL_AGENT_DLP_MODE=enforce");
|
|
23
23
|
const tenant = String(tenantId).toLowerCase();
|
|
@@ -52,7 +52,7 @@ export function installContentHandlers({ link, tenantId, wsManager, getWorkspace
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
// F3-07c: the listing handler mints through mintRef, so every id it hands out is also announced with content.index
|
|
55
|
-
const handler = createContentHandler({ session: link.session, tenantId: tenant, resolveWorkspace, registry, config, env, log, mint: mintRef });
|
|
55
|
+
const handler = createContentHandler({ session: link.session, tenantId: tenant, resolveWorkspace, registry, config, env, log, mint: mintRef, chat });
|
|
56
56
|
link.session.setHandler("content.request", (ctx) => handler.handle(ctx));
|
|
57
57
|
indexer = createContentIndexer({ session: link.session, registry, config, log });
|
|
58
58
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* job-stream.js - `content.request kind=job.stream` (contract 2.3, LA-3): one long-poll page of the event stream of a chat job, read from the ChatStore of THIS
|
|
3
|
+
* machine. The answer is client content in transit: masked piece by piece (DLP enforce; an event line on its decoded JSON strings, see maskEventLine), sent as a `content.response` and never stored or logged by the control plane.
|
|
4
|
+
*
|
|
5
|
+
* ref = the job UUID first request: from the start of the stream
|
|
6
|
+
* ref = a `nextRef` (43 chars) the cursor this agent minted with the previous page: after the last event of that page (cursors.js; asking again with the
|
|
7
|
+
* same cursor gives the same page)
|
|
8
|
+
* body {events:[{i, e}], nextRef, done} (schema job.stream.body.schema.json)
|
|
9
|
+
*
|
|
10
|
+
* The request is held up to LOCAL_AGENT_CHAT_POLL_MS (8 s) while there is nothing new and the job is alive; an empty page with done:false means "ask again".
|
|
11
|
+
* A job the store does not know (or that belongs to another workspace of the envelope) and a cursor it does not know are `not_found`, whatever the reason.
|
|
12
|
+
*/
|
|
13
|
+
import { maskEventLine, WITHHELD_LINE } from "../chat/dlp.js";
|
|
14
|
+
|
|
15
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
16
|
+
const CURSOR = /^[A-Za-z0-9_-]{43}$/;
|
|
17
|
+
const MAX_LINE_CHARS = 16_384;
|
|
18
|
+
const PAGE_OVERHEAD = 128; // {"events":[],"nextRef":"<43>","done":false} and the commas
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} d { store, cursors, chatConfig, env, maxBytes }
|
|
22
|
+
* @returns {Promise<{status:string, errorCode?:string, buffer?:Buffer, contentType?:string, dlp?:object}>}
|
|
23
|
+
*/
|
|
24
|
+
export async function produceJobStream({ store, cursors, chatConfig, env, maxBytes }, ctx, p, signal) {
|
|
25
|
+
if (p.range) return { status: "error", errorCode: "protocol_unsupported" }; // a stream page has no byte range
|
|
26
|
+
let jobId;
|
|
27
|
+
let from = 0;
|
|
28
|
+
if (typeof p.ref === "string" && UUID.test(p.ref)) jobId = p.ref;
|
|
29
|
+
else if (typeof p.ref === "string" && CURSOR.test(p.ref)) {
|
|
30
|
+
const c = cursors.resolve(p.ref, { workspaceId: ctx.workspaceId });
|
|
31
|
+
if (!c) return { status: "not_found" };
|
|
32
|
+
jobId = c.jobId; from = c.next;
|
|
33
|
+
} else return { status: "not_found" };
|
|
34
|
+
|
|
35
|
+
const r = await store.readStream({ conversationId: jobId, workspaceId: ctx.workspaceId, from, waitMs: chatConfig.pollMs, signal });
|
|
36
|
+
if (r.status === "aborted") return { status: "error", errorCode: "agent_offline" };
|
|
37
|
+
if (r.status !== "ok") return { status: "not_found" };
|
|
38
|
+
|
|
39
|
+
// egress: each event line is masked on its own, on its decoded strings and re-serialized (maskEventLine); the page is cut so that the body fits one content.response
|
|
40
|
+
let total = 0;
|
|
41
|
+
const redactions = {};
|
|
42
|
+
const events = [];
|
|
43
|
+
let size = PAGE_OVERHEAD;
|
|
44
|
+
for (const ev of r.events) {
|
|
45
|
+
const m = maskEventLine(ev.e, WITHHELD_LINE, { env, source: "content.job.stream" });
|
|
46
|
+
let e = m.text;
|
|
47
|
+
if (e.length > MAX_LINE_CHARS || e.length === 0) e = WITHHELD_LINE; // a mask that grew the line past the contract's limit: withheld, never cut
|
|
48
|
+
const add = JSON.stringify(e).length + 16 + String(ev.i).length;
|
|
49
|
+
if (events.length > 0 && size + add > maxBytes) break;
|
|
50
|
+
if (events.length === 0 && size + add > maxBytes) return { status: "error", errorCode: "payload_too_large" };
|
|
51
|
+
size += add;
|
|
52
|
+
events.push({ i: ev.i, e });
|
|
53
|
+
total += m.total;
|
|
54
|
+
for (const [k, n] of Object.entries(m.redactions || {})) redactions[k] = (redactions[k] || 0) + n;
|
|
55
|
+
}
|
|
56
|
+
const next = events.length ? events[events.length - 1].i + 1 : r.next;
|
|
57
|
+
const done = r.done && next >= r.next;
|
|
58
|
+
const nextRef = cursors.mint({ workspaceId: ctx.workspaceId, jobId, next });
|
|
59
|
+
const buffer = Buffer.from(JSON.stringify({ events, nextRef, done }), "utf8");
|
|
60
|
+
if (buffer.length > maxBytes) return { status: "error", errorCode: "payload_too_large" };
|
|
61
|
+
return { status: "ok", buffer, contentType: "application/json", dlp: { mode: "enforce", total, redactions } };
|
|
62
|
+
}
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
"repo": "VISIONHAMMER/vh-api-gateway",
|
|
3
3
|
"path": "docs/contracts/la-protocol-v2",
|
|
4
4
|
"branch": "release/qa",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "a2a5f67c3b48f8dc616fdd441d126441d714e5a4",
|
|
6
6
|
"note": "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
|
|
7
7
|
"files": {
|
|
8
|
-
"README.md": "
|
|
8
|
+
"README.md": "06f54d7a309ebc36175dedae483cb2366dd2eeccf10f4589e300871142403bfb",
|
|
9
9
|
"ack.schema.json": "d6dfdaa8b1ec2dd47db9e858cd047ca860196ab6b0ad350f8ccb5d2596441ccf",
|
|
10
10
|
"alert.schema.json": "01a8a77840be8ab7901ed7d07c3d68a2295bfaeb638694564e19e60be1747054",
|
|
11
11
|
"approval.decision.schema.json": "a9c7bc74d146465afde1d5830839165a02f232b830b5f6cfd8c8e91fb6da1563",
|
|
12
12
|
"audit.event.schema.json": "e9ceb58ea00077151373955957b8319324de488c80da4063f6d0dcb40a8b3ed0",
|
|
13
|
+
"chat.session.body.schema.json": "f172821026471f82440207e964a0ae193c3f7b9b2816b63ed134d4910035a30d",
|
|
13
14
|
"config.update.schema.json": "432438b0c93ddafa0741e47f99b7357ad5f2b06b606779bf846c282e2f8a5639",
|
|
14
15
|
"content.index.schema.json": "1abc24344435f0f392d5b626c3abb6879d8f9dd2c1d0463840a67634a5caa71e",
|
|
15
16
|
"content.request.schema.json": "440e3675246657e9aae23e51176168bc21c91283b5fcaf6782111a18110bf76e",
|
|
@@ -21,11 +22,12 @@
|
|
|
21
22
|
"heartbeat.schema.json": "8aff84a1ef3ca56bdff8819abb3b0ab01a2547814c2ea2f9ee893cb08f7a08f1",
|
|
22
23
|
"hello.schema.json": "a90a6bf0e9a8a20e4c6edcf2137931cf044846e7924977228275ef648a48934f",
|
|
23
24
|
"incident.candidate.schema.json": "ffec36aca6916079debc2c6060d093819ea3ea660b57c0c92ac3b541a0e99000",
|
|
24
|
-
"index.json": "
|
|
25
|
-
"job.assign.schema.json": "
|
|
25
|
+
"index.json": "b40a15480a4b038de6a08da4b53a369d462bc9cf75802593a390d7ad118f95ed",
|
|
26
|
+
"job.assign.schema.json": "0234e0cec55c03b97ac40497b514c9f0c6a66f28d8b975dc6506de6f72b7d579",
|
|
26
27
|
"job.cancel.schema.json": "40ef64373283e17306d857c9a40081689786015294b0b88ab8f68f063bd2cfb8",
|
|
27
28
|
"job.event.schema.json": "493d08f9db4f7ffd4a97403ee33ca4a73a65dc8f774d8f18ef0a137fdb5fc470",
|
|
28
29
|
"job.result.schema.json": "9cbc61211482557283b8f4df44e79011ead1abbacdb2d986138199c747edc043",
|
|
30
|
+
"job.stream.body.schema.json": "2536d9f890c3000d245f20fdacc45b8cba24d2b1e4ef63a3116dd9611541b658",
|
|
29
31
|
"log.stats.schema.json": "4b33b111946a19793a60756793294e90911b06f7050e48821759f2497ec17641",
|
|
30
32
|
"policy.check.schema.json": "ebd3b99628bc827784b495b239656fc918cc7ce792ee5554372dba084bbdcc09",
|
|
31
33
|
"policy.decision.schema.json": "84c1f14a1bfa698573d8cc621f4d6189f4ef09baa37e5e990fb94723e73296fe",
|
|
@@ -38,25 +40,27 @@
|
|
|
38
40
|
"vectors/alert.vectors.json": "0bf179ff390bbecf07a117b80237cb7ac8778864e5780f36d68c8c9a64db8f04",
|
|
39
41
|
"vectors/approval.decision.vectors.json": "338115973bf2c4390d2bb33ce8f4079f3132cd739cd5505e1e873fdc252d2271",
|
|
40
42
|
"vectors/audit.event.vectors.json": "d05c1820103eef041a84eb8b13d9a67a8a5b54b9b54ee3b6e43fd4ef15ed7000",
|
|
43
|
+
"vectors/chat.session.body.vectors.json": "2f5311e05e4f65f371dbb476b02bf0b1408fba21f95f5fbaf0a00d051f978788",
|
|
41
44
|
"vectors/config.update.vectors.json": "e61865fad9b4efa808e02877c4352d3720c8069a0b31261701b369d8ae11f9cf",
|
|
42
45
|
"vectors/content.index.vectors.json": "f167db30910989c46f317313d651ca59f9cd9d881d2ea81b84912155d18227b7",
|
|
43
|
-
"vectors/content.request.vectors.json": "
|
|
46
|
+
"vectors/content.request.vectors.json": "5f751ceb0645cad079bce7e45d14e764973799e663c891a9200b3d8b38712f38",
|
|
44
47
|
"vectors/content.response.vectors.json": "cbb47cbf8a9512aa5360eea425f0cdd9064334311d37834dc75bddff07dadc5d",
|
|
45
48
|
"vectors/cross-type.vectors.json": "f7ed403d3e3f53150b715a5423fde1bed708e8c6245709ed4ad74389dc8c3307",
|
|
46
49
|
"vectors/error.vectors.json": "f5fd7954f2c1736ee0943eb330c2f85b7fdd7a8959e4f043f9cc9e9bcda39aa5",
|
|
47
50
|
"vectors/file.list.body.vectors.json": "da1dcc5d4ec976853161bef4b3947572f36b5526cbe16b838ac4ef72ddded403",
|
|
48
51
|
"vectors/heartbeat.vectors.json": "cfd2e048139c67aa925f1fa94c05326e75248839645c1a1e4d6d3bb60cafa624",
|
|
49
|
-
"vectors/hello.vectors.json": "
|
|
52
|
+
"vectors/hello.vectors.json": "0beae9adf4dd69c9e71951d8fa7b3870543a36a3c80b9d8a18ec69f9c3d7f4d4",
|
|
50
53
|
"vectors/incident.candidate.vectors.json": "a78eb4b103f411f1ec64d9eeb4c39623851e710ac0cc20b4d78b2739b7ff78d5",
|
|
51
|
-
"vectors/job.assign.vectors.json": "
|
|
54
|
+
"vectors/job.assign.vectors.json": "1062e2ea97e0ac33ce04bfc45dd38cad3a9e520df6c7400941d697db84e83767",
|
|
52
55
|
"vectors/job.cancel.vectors.json": "0fc9f99c8662435e9425389068adf418273489dc73928a21eae1b283256b9bc5",
|
|
53
56
|
"vectors/job.event.vectors.json": "03a2a2435918ba561821916bf4e435f2780210900139ecf68091acac6abf200c",
|
|
54
57
|
"vectors/job.result.vectors.json": "05995fbe65e5357705d75e9b660b517f54c6902069d36b62190b1b79f283e7a5",
|
|
58
|
+
"vectors/job.stream.body.vectors.json": "c7b0748628f3711cd8296cc36ea6bf3829315feaaf9ad576b229ad9739a56392",
|
|
55
59
|
"vectors/log.stats.vectors.json": "a8a67e09ef0f87b81f4028b77427ed7afd809c3f8534b8423a712b2ceef9a27f",
|
|
56
60
|
"vectors/policy.check.vectors.json": "7222d74b010e304af5733ed2802c9a907f7186351d3d4b5eedfe6ff1e1f2fc8d",
|
|
57
61
|
"vectors/policy.decision.vectors.json": "9fd61a5198568f4471dc28a2ce52eaa768f065d9cb7c76419af0c47f28295b09",
|
|
58
62
|
"vectors/ready.vectors.json": "16327f5e5eccc57a0ec635bcde96116feeeeec7ddbc4b621d4d070738cdc48d5",
|
|
59
|
-
"vectors/register.vectors.json": "
|
|
63
|
+
"vectors/register.vectors.json": "0ee59c72c8ad8835092c103142a3f561538cc47980c4d5dfb8374f18d649217c",
|
|
60
64
|
"vectors/scan.upload.vectors.json": "e8a6f73fe91ac58a3222b68cf28124c1254aa4f00c3ef8f472deb7de31cab76e",
|
|
61
65
|
"vectors/scan.uploaded.vectors.json": "7314ea15b1afa7b9625048fb7e245492d8910bc0dae8a52ea62afe2a1e240382",
|
|
62
66
|
"vectors/traces/invalid-agent-mismatch.json": "5f17b867de71c1bcc6c42723acb53a50338c0e491193499ea25c2220ea4fa558",
|
|
@@ -87,6 +91,7 @@
|
|
|
87
91
|
"vectors/traces/invalid-webhook-chunk-out-of-order.json": "b6baa056b4e3347deaf0a4b901a126a3f93d20627faaa3951028bd47894715f4",
|
|
88
92
|
"vectors/traces/invalid-webhook-chunk-sha-mismatch.json": "1bb79d0e1794cd791625ed2a7eab154e20150c5d7bee7c86aa97f9bf4aaeab6d",
|
|
89
93
|
"vectors/traces/invalid-webhook-chunk-total-changes.json": "5edf3c1916927f94ef15061a13a6ded8a0168cb0492158f34db8160c0d6bdbe4",
|
|
94
|
+
"vectors/traces/valid-chat-job-and-stream.json": "b9ad5e045e9409737cb1a2607cfc652dc6f178ae266cc144875731fcbfeaa692",
|
|
90
95
|
"vectors/traces/valid-content-chunks-in-order.json": "c5fc301527b062b30c625da2798c7cfab3e3debac29a36f6ec7c699705267ab3",
|
|
91
96
|
"vectors/traces/valid-content-index.json": "9305dc955c4cca1f09158a9c8cf21d9e89d6a6b03646088c547f64a61cd59c30",
|
|
92
97
|
"vectors/traces/valid-handshake-and-job.json": "9aa3c423c496403ca77d63965f1805f0a57ea9f6f8a01351310515b265aeb9cc",
|