@intx/hub-api 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/package.json +28 -0
- package/src/app.test.ts +225 -0
- package/src/app.ts +382 -0
- package/src/auth.ts +21 -0
- package/src/context.ts +38 -0
- package/src/format.ts +9 -0
- package/src/git-http/advertise-refs.test.ts +459 -0
- package/src/git-http/advertise-refs.ts +226 -0
- package/src/git-http/pkt-line.test.ts +220 -0
- package/src/git-http/pkt-line.ts +235 -0
- package/src/git-http/receive-pack.test.ts +397 -0
- package/src/git-http/receive-pack.ts +261 -0
- package/src/git-http/side-band-64k.test.ts +181 -0
- package/src/git-http/side-band-64k.ts +134 -0
- package/src/git-http/upload-pack.test.ts +545 -0
- package/src/git-http/upload-pack.ts +396 -0
- package/src/index.ts +23 -0
- package/src/middleware/git-token-auth.test.ts +587 -0
- package/src/middleware/git-token-auth.ts +315 -0
- package/src/middleware/grant.ts +106 -0
- package/src/middleware/session.ts +13 -0
- package/src/middleware/tenant.test.ts +192 -0
- package/src/middleware/tenant.ts +101 -0
- package/src/openapi.ts +66 -0
- package/src/pagination.ts +117 -0
- package/src/routes/agent-data.ts +179 -0
- package/src/routes/agent-state-git.ts +562 -0
- package/src/routes/agents.test.ts +337 -0
- package/src/routes/agents.ts +704 -0
- package/src/routes/approvals.ts +130 -0
- package/src/routes/assets.test.ts +567 -0
- package/src/routes/assets.ts +592 -0
- package/src/routes/credentials.ts +435 -0
- package/src/routes/git-tokens.test.ts +709 -0
- package/src/routes/git-tokens.ts +771 -0
- package/src/routes/grants.ts +509 -0
- package/src/routes/instances.test.ts +1103 -0
- package/src/routes/instances.ts +1797 -0
- package/src/routes/me.ts +405 -0
- package/src/routes/oauth-clients.ts +349 -0
- package/src/routes/observability.ts +146 -0
- package/src/routes/offerings.ts +382 -0
- package/src/routes/principals.ts +515 -0
- package/src/routes/providers.ts +351 -0
- package/src/routes/roles.ts +452 -0
- package/src/routes/sidecars.ts +221 -0
- package/src/routes/tenant-federation.ts +225 -0
- package/src/routes/tenants.ts +369 -0
- package/src/routes/wallets.ts +370 -0
- package/src/session.ts +44 -0
- package/src/timeline-reconstruction.test.ts +786 -0
- package/src/timeline-reconstruction.ts +383 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import git from "isomorphic-git";
|
|
3
|
+
import { type } from "arktype";
|
|
4
|
+
import {
|
|
5
|
+
IsogitStore,
|
|
6
|
+
listMail,
|
|
7
|
+
type MailDirection,
|
|
8
|
+
} from "@intx/storage-isogit";
|
|
9
|
+
import type { ConversationTurn } from "@intx/types/runtime";
|
|
10
|
+
import {
|
|
11
|
+
ErrorRecord,
|
|
12
|
+
type ErrorRecord as ErrorRecordType,
|
|
13
|
+
} from "@intx/types/audit";
|
|
14
|
+
|
|
15
|
+
export type ReconstructedEvent =
|
|
16
|
+
| {
|
|
17
|
+
kind: "mail";
|
|
18
|
+
direction: MailDirection;
|
|
19
|
+
messageId: string;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
raw: Uint8Array;
|
|
22
|
+
checkpointHash?: string;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
kind: "turn";
|
|
26
|
+
content: string;
|
|
27
|
+
timestamp: number;
|
|
28
|
+
status: "completed" | "error" | "in-progress";
|
|
29
|
+
isError?: boolean;
|
|
30
|
+
errors?: { category: string; message: string }[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type GapKind =
|
|
34
|
+
| "message-count-regression"
|
|
35
|
+
| "corrupt-checkpoint"
|
|
36
|
+
| "corrupt-error-record"
|
|
37
|
+
| "orphan-mail";
|
|
38
|
+
|
|
39
|
+
export type ReconstructionGap = {
|
|
40
|
+
kind: GapKind;
|
|
41
|
+
description: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ReconstructionResult = {
|
|
45
|
+
events: ReconstructedEvent[];
|
|
46
|
+
gaps: ReconstructionGap[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ERRORS_DIR = "state/errors";
|
|
50
|
+
const MAX_LOG_DEPTH = 10000;
|
|
51
|
+
|
|
52
|
+
type CheckpointReason =
|
|
53
|
+
| "inference-done"
|
|
54
|
+
| "tool-execution"
|
|
55
|
+
| "tool-done"
|
|
56
|
+
| "inference-error"
|
|
57
|
+
| "gate-cleared";
|
|
58
|
+
|
|
59
|
+
function isCheckpointReason(value: string): value is CheckpointReason {
|
|
60
|
+
return (
|
|
61
|
+
value === "inference-done" ||
|
|
62
|
+
value === "tool-execution" ||
|
|
63
|
+
value === "tool-done" ||
|
|
64
|
+
value === "inference-error" ||
|
|
65
|
+
value === "gate-cleared"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseCheckpointReason(commitMessage: string): CheckpointReason | null {
|
|
70
|
+
const match = /^checkpoint: (.+)$/.exec(commitMessage);
|
|
71
|
+
if (match?.[1] === undefined) return null;
|
|
72
|
+
if (!isCheckpointReason(match[1])) return null;
|
|
73
|
+
return match[1];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function reasonToStatus(
|
|
77
|
+
reason: CheckpointReason,
|
|
78
|
+
): "completed" | "error" | "in-progress" {
|
|
79
|
+
switch (reason) {
|
|
80
|
+
case "inference-done":
|
|
81
|
+
return "completed";
|
|
82
|
+
case "inference-error":
|
|
83
|
+
return "error";
|
|
84
|
+
case "tool-execution":
|
|
85
|
+
case "tool-done":
|
|
86
|
+
case "gate-cleared":
|
|
87
|
+
return "in-progress";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isToolResultTurn(msg: ConversationTurn): boolean {
|
|
92
|
+
const first = msg.content[0];
|
|
93
|
+
return (
|
|
94
|
+
msg.role === "user" && first !== undefined && first.type === "tool_result"
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function extractTextContent(msg: ConversationTurn): string {
|
|
99
|
+
// Treat refusal blocks as text for timeline-summary purposes. A
|
|
100
|
+
// refusal-only assistant turn carries human-readable model output
|
|
101
|
+
// (the model declined a structured-output request and explained
|
|
102
|
+
// why); summarising it as the empty string would render the turn
|
|
103
|
+
// invisible on the timeline even though the model produced
|
|
104
|
+
// coherent content. The structural "this was a refusal" signal is
|
|
105
|
+
// preserved on the persisted turn-part kind for any consumer that
|
|
106
|
+
// wants to render policy declines differently.
|
|
107
|
+
return msg.content
|
|
108
|
+
.map((b) => {
|
|
109
|
+
if (b.type === "text") return b.text;
|
|
110
|
+
if (b.type === "refusal") return b.reason;
|
|
111
|
+
return "";
|
|
112
|
+
})
|
|
113
|
+
.join("");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type TurnAccumulator = {
|
|
117
|
+
texts: string[];
|
|
118
|
+
timestamp: number;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function extractTurns(
|
|
122
|
+
newMessages: ConversationTurn[],
|
|
123
|
+
status: "completed" | "error" | "in-progress",
|
|
124
|
+
): ReconstructedEvent[] {
|
|
125
|
+
const events: ReconstructedEvent[] = [];
|
|
126
|
+
let current: TurnAccumulator | null = null;
|
|
127
|
+
|
|
128
|
+
for (const msg of newMessages) {
|
|
129
|
+
if (msg.role === "user" && !isToolResultTurn(msg)) {
|
|
130
|
+
// New user message (not a tool result) starts a new turn
|
|
131
|
+
if (current !== null && current.texts.length > 0) {
|
|
132
|
+
events.push({
|
|
133
|
+
kind: "turn",
|
|
134
|
+
content: current.texts.join(""),
|
|
135
|
+
timestamp: current.timestamp,
|
|
136
|
+
status,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
current = { texts: [], timestamp: msg.timestamp };
|
|
140
|
+
} else if (msg.role === "assistant") {
|
|
141
|
+
if (current === null) {
|
|
142
|
+
current = { texts: [], timestamp: msg.timestamp };
|
|
143
|
+
} else {
|
|
144
|
+
// Update timestamp to the latest assistant message in this turn
|
|
145
|
+
current.timestamp = msg.timestamp;
|
|
146
|
+
}
|
|
147
|
+
const text = extractTextContent(msg);
|
|
148
|
+
if (text.length > 0) {
|
|
149
|
+
current.texts.push(text);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// tool_result user messages and system messages are continuations, not new turns
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (current !== null && current.texts.length > 0) {
|
|
156
|
+
events.push({
|
|
157
|
+
kind: "turn",
|
|
158
|
+
content: current.texts.join(""),
|
|
159
|
+
timestamp: current.timestamp,
|
|
160
|
+
status,
|
|
161
|
+
...(status === "error" ? { isError: true } : {}),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return events;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type ErrorReadResult = {
|
|
169
|
+
errors: ErrorRecordType[];
|
|
170
|
+
corruptFiles: string[];
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const ERROR_COMMIT_PATTERN = /^Record \d+ error records?$/;
|
|
174
|
+
|
|
175
|
+
async function readErrorRecordsFromCommit(
|
|
176
|
+
dir: string,
|
|
177
|
+
oid: string,
|
|
178
|
+
): Promise<ErrorReadResult> {
|
|
179
|
+
const errors: ErrorRecordType[] = [];
|
|
180
|
+
const corruptFiles: string[] = [];
|
|
181
|
+
|
|
182
|
+
// Walk state/errors/ in the commit tree
|
|
183
|
+
let sessionTree;
|
|
184
|
+
try {
|
|
185
|
+
sessionTree = await git.readTree({ fs, dir, oid, filepath: ERRORS_DIR });
|
|
186
|
+
} catch {
|
|
187
|
+
return { errors: [], corruptFiles: [] };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const sessionEntry of sessionTree.tree) {
|
|
191
|
+
if (sessionEntry.type !== "tree") continue;
|
|
192
|
+
const sessionId = sessionEntry.path;
|
|
193
|
+
|
|
194
|
+
let fileTree;
|
|
195
|
+
try {
|
|
196
|
+
fileTree = await git.readTree({
|
|
197
|
+
fs,
|
|
198
|
+
dir,
|
|
199
|
+
oid,
|
|
200
|
+
filepath: `${ERRORS_DIR}/${sessionId}`,
|
|
201
|
+
});
|
|
202
|
+
} catch {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const fileEntry of fileTree.tree) {
|
|
207
|
+
if (fileEntry.type !== "blob" || !fileEntry.path.endsWith(".json"))
|
|
208
|
+
continue;
|
|
209
|
+
|
|
210
|
+
let blob: Uint8Array;
|
|
211
|
+
try {
|
|
212
|
+
({ blob } = await git.readBlob({ fs, dir, oid: fileEntry.oid }));
|
|
213
|
+
} catch {
|
|
214
|
+
corruptFiles.push(`${sessionId}/${fileEntry.path}`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const text = new TextDecoder().decode(blob);
|
|
219
|
+
let parsed: unknown;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(text);
|
|
222
|
+
} catch {
|
|
223
|
+
corruptFiles.push(`${sessionId}/${fileEntry.path}`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const result = ErrorRecord(parsed);
|
|
228
|
+
if (result instanceof type.errors) {
|
|
229
|
+
corruptFiles.push(`${sessionId}/${fileEntry.path}`);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
errors.push(result);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { errors, corruptFiles };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function reconstructTimeline(
|
|
240
|
+
dir: string,
|
|
241
|
+
): Promise<ReconstructionResult> {
|
|
242
|
+
const store = new IsogitStore(dir);
|
|
243
|
+
const events: ReconstructedEvent[] = [];
|
|
244
|
+
const gaps: ReconstructionGap[] = [];
|
|
245
|
+
const gapKindsAdded = new Set<GapKind>();
|
|
246
|
+
|
|
247
|
+
function addGap(kind: GapKind, description: string): void {
|
|
248
|
+
if (gapKindsAdded.has(kind)) return;
|
|
249
|
+
gapKindsAdded.add(kind);
|
|
250
|
+
gaps.push({ kind, description });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Walk the full commit log, oldest first
|
|
254
|
+
const commits = await store.log(MAX_LOG_DEPTH);
|
|
255
|
+
commits.reverse();
|
|
256
|
+
|
|
257
|
+
// Process commits to extract turns and associate errors
|
|
258
|
+
let prevMessageCount = 0;
|
|
259
|
+
// Track the index of the last turn event so error commits can attach to it
|
|
260
|
+
let lastTurnEventIndex = -1;
|
|
261
|
+
|
|
262
|
+
for (const commit of commits) {
|
|
263
|
+
// Handle error commits — associate with the most recent turn
|
|
264
|
+
if (ERROR_COMMIT_PATTERN.test(commit.message)) {
|
|
265
|
+
const { errors: errorRecords, corruptFiles } =
|
|
266
|
+
await readErrorRecordsFromCommit(dir, commit.hash);
|
|
267
|
+
|
|
268
|
+
for (const file of corruptFiles) {
|
|
269
|
+
gaps.push({
|
|
270
|
+
kind: "corrupt-error-record",
|
|
271
|
+
description: `Error record ${file} failed validation and was excluded from the timeline`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (errorRecords.length > 0 && lastTurnEventIndex >= 0) {
|
|
276
|
+
const turnEvent = events[lastTurnEventIndex];
|
|
277
|
+
if (turnEvent !== undefined && turnEvent.kind === "turn") {
|
|
278
|
+
turnEvent.isError = true;
|
|
279
|
+
turnEvent.errors = [
|
|
280
|
+
...(turnEvent.errors ?? []),
|
|
281
|
+
...errorRecords.map((e) => ({
|
|
282
|
+
category: e.category,
|
|
283
|
+
message: e.message,
|
|
284
|
+
})),
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const reason = parseCheckpointReason(commit.message);
|
|
292
|
+
if (reason === null) continue;
|
|
293
|
+
|
|
294
|
+
const status = reasonToStatus(reason);
|
|
295
|
+
|
|
296
|
+
let messages: ConversationTurn[];
|
|
297
|
+
try {
|
|
298
|
+
messages = await store.readAt(commit.hash);
|
|
299
|
+
} catch {
|
|
300
|
+
gaps.push({
|
|
301
|
+
kind: "corrupt-checkpoint",
|
|
302
|
+
description: `Checkpoint commit ${commit.hash} could not be read; this checkpoint is missing from the reconstructed timeline`,
|
|
303
|
+
});
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (messages.length < prevMessageCount) {
|
|
308
|
+
addGap(
|
|
309
|
+
"message-count-regression",
|
|
310
|
+
`Message count dropped from ${prevMessageCount} to ${messages.length} at commit ${commit.hash}`,
|
|
311
|
+
);
|
|
312
|
+
// Treat the entire message array as new for this checkpoint
|
|
313
|
+
const turnEvents = extractTurns(messages, status);
|
|
314
|
+
events.push(...turnEvents);
|
|
315
|
+
if (turnEvents.length > 0) {
|
|
316
|
+
lastTurnEventIndex = events.length - 1;
|
|
317
|
+
}
|
|
318
|
+
prevMessageCount = messages.length;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const newMessages = messages.slice(prevMessageCount);
|
|
323
|
+
prevMessageCount = messages.length;
|
|
324
|
+
|
|
325
|
+
if (newMessages.length === 0) continue;
|
|
326
|
+
|
|
327
|
+
const turnEvents = extractTurns(newMessages, status);
|
|
328
|
+
events.push(...turnEvents);
|
|
329
|
+
if (turnEvents.length > 0) {
|
|
330
|
+
lastTurnEventIndex = events.length - 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Process mail entries
|
|
335
|
+
const mailEntries = await listMail(dir);
|
|
336
|
+
// Correlate mail timestamps and checkpoint linkage with git commits.
|
|
337
|
+
// The Checkpoint trailer regex uses $ to anchor at end-of-string. This
|
|
338
|
+
// relies on IsogitStore.log() calling trimEnd() on commit messages to
|
|
339
|
+
// strip the trailing newline that isomorphic-git appends.
|
|
340
|
+
const mailCommitMeta = new Map<
|
|
341
|
+
string,
|
|
342
|
+
{ timestamp: number; checkpointHash?: string }
|
|
343
|
+
>();
|
|
344
|
+
for (const commit of commits) {
|
|
345
|
+
const match = /^Record (?:inbound|outbound) mail (.+)$/m.exec(
|
|
346
|
+
commit.message,
|
|
347
|
+
);
|
|
348
|
+
if (match?.[1] !== undefined) {
|
|
349
|
+
const checkpointMatch = /\nCheckpoint: ([0-9a-f]+)$/.exec(commit.message);
|
|
350
|
+
mailCommitMeta.set(match[1], {
|
|
351
|
+
timestamp: commit.timestamp,
|
|
352
|
+
...(checkpointMatch?.[1] !== undefined
|
|
353
|
+
? { checkpointHash: checkpointMatch[1] }
|
|
354
|
+
: {}),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
for (const entry of mailEntries) {
|
|
360
|
+
const meta = mailCommitMeta.get(entry.messageId);
|
|
361
|
+
if (meta === undefined) {
|
|
362
|
+
gaps.push({
|
|
363
|
+
kind: "orphan-mail",
|
|
364
|
+
description: `Mail entry ${entry.messageId} has no corresponding git commit; its timestamp is unreliable`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
events.push({
|
|
368
|
+
kind: "mail",
|
|
369
|
+
direction: entry.direction,
|
|
370
|
+
messageId: entry.messageId,
|
|
371
|
+
timestamp: meta?.timestamp ?? 0,
|
|
372
|
+
raw: entry.raw,
|
|
373
|
+
...(meta?.checkpointHash !== undefined
|
|
374
|
+
? { checkpointHash: meta.checkpointHash }
|
|
375
|
+
: {}),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Sort all events by timestamp
|
|
380
|
+
events.sort((a, b) => a.timestamp - b.timestamp);
|
|
381
|
+
|
|
382
|
+
return { events, gaps };
|
|
383
|
+
}
|
package/tsconfig.json
ADDED