@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.
Files changed (55) hide show
  1. package/README.md +29 -0
  2. package/package.json +28 -0
  3. package/src/app.test.ts +225 -0
  4. package/src/app.ts +382 -0
  5. package/src/auth.ts +21 -0
  6. package/src/context.ts +38 -0
  7. package/src/format.ts +9 -0
  8. package/src/git-http/advertise-refs.test.ts +459 -0
  9. package/src/git-http/advertise-refs.ts +226 -0
  10. package/src/git-http/pkt-line.test.ts +220 -0
  11. package/src/git-http/pkt-line.ts +235 -0
  12. package/src/git-http/receive-pack.test.ts +397 -0
  13. package/src/git-http/receive-pack.ts +261 -0
  14. package/src/git-http/side-band-64k.test.ts +181 -0
  15. package/src/git-http/side-band-64k.ts +134 -0
  16. package/src/git-http/upload-pack.test.ts +545 -0
  17. package/src/git-http/upload-pack.ts +396 -0
  18. package/src/index.ts +23 -0
  19. package/src/middleware/git-token-auth.test.ts +587 -0
  20. package/src/middleware/git-token-auth.ts +315 -0
  21. package/src/middleware/grant.ts +106 -0
  22. package/src/middleware/session.ts +13 -0
  23. package/src/middleware/tenant.test.ts +192 -0
  24. package/src/middleware/tenant.ts +101 -0
  25. package/src/openapi.ts +66 -0
  26. package/src/pagination.ts +117 -0
  27. package/src/routes/agent-data.ts +179 -0
  28. package/src/routes/agent-state-git.ts +562 -0
  29. package/src/routes/agents.test.ts +337 -0
  30. package/src/routes/agents.ts +704 -0
  31. package/src/routes/approvals.ts +130 -0
  32. package/src/routes/assets.test.ts +567 -0
  33. package/src/routes/assets.ts +592 -0
  34. package/src/routes/credentials.ts +435 -0
  35. package/src/routes/git-tokens.test.ts +709 -0
  36. package/src/routes/git-tokens.ts +771 -0
  37. package/src/routes/grants.ts +509 -0
  38. package/src/routes/instances.test.ts +1103 -0
  39. package/src/routes/instances.ts +1797 -0
  40. package/src/routes/me.ts +405 -0
  41. package/src/routes/oauth-clients.ts +349 -0
  42. package/src/routes/observability.ts +146 -0
  43. package/src/routes/offerings.ts +382 -0
  44. package/src/routes/principals.ts +515 -0
  45. package/src/routes/providers.ts +351 -0
  46. package/src/routes/roles.ts +452 -0
  47. package/src/routes/sidecars.ts +221 -0
  48. package/src/routes/tenant-federation.ts +225 -0
  49. package/src/routes/tenants.ts +369 -0
  50. package/src/routes/wallets.ts +370 -0
  51. package/src/session.ts +44 -0
  52. package/src/timeline-reconstruction.test.ts +786 -0
  53. package/src/timeline-reconstruction.ts +383 -0
  54. package/tsconfig.json +4 -0
  55. 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
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src/**/*.ts"]
4
+ }