@inixiative/archive 0.5.1 → 0.7.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/src/index.ts CHANGED
@@ -11,6 +11,22 @@ export const archiveEntrySchema = z.strictObject({
11
11
  text: z.string().max(2_000_000),
12
12
  timestamp: z.number().finite().nonnegative().nullable(),
13
13
  sourceRef: z.string().min(1).max(512),
14
+ /** The model that produced the entry, and its effort level as the harness names it. */
15
+ model: z.string().min(1).max(200).optional(),
16
+ effort: z.string().min(1).max(40).optional(),
17
+ });
18
+ /** Who did the work: a person, a service such as an API-key run, or a job (id = job name). */
19
+ export const archiveActorSchema = z.strictObject({
20
+ kind: z.enum(['user', 'service', 'job']),
21
+ id: identifier,
22
+ name: z.string().min(1).max(200).optional(),
23
+ /** The integration the work ran under, when there is one. */
24
+ integrationId: identifier.optional(),
25
+ });
26
+ /** An item in an integration (a GitHub pull request, a Linear issue) the session is about. */
27
+ export const archiveReferenceSchema = z.strictObject({
28
+ integration: z.string().regex(/^[a-z0-9-]{1,64}$/),
29
+ ref: z.string().min(1).max(300),
14
30
  });
15
31
  export const archiveSnapshotSchema = z
16
32
  .strictObject({
@@ -20,6 +36,9 @@ export const archiveSnapshotSchema = z
20
36
  sessionId: identifier,
21
37
  title: z.string().min(1).max(500),
22
38
  projectId: identifier.optional(),
39
+ actor: archiveActorSchema.optional(),
40
+ /** Recorded references, such as the session's repository and branch. Linked ones are found in the text. */
41
+ references: z.array(archiveReferenceSchema).max(100).optional(),
23
42
  tags: z.array(z.string().min(1).max(120)).max(100),
24
43
  goalIds: z.array(identifier).max(100).default([]),
25
44
  runIds: z.array(identifier).max(100).default([]),
@@ -39,6 +58,8 @@ export const archiveSnapshotSchema = z
39
58
  });
40
59
  export type ArchiveSnapshot = z.infer<typeof archiveSnapshotSchema>;
41
60
  export type ArchiveEntry = z.infer<typeof archiveEntrySchema>;
61
+ export type ArchiveActor = z.infer<typeof archiveActorSchema>;
62
+ export type ArchiveReference = z.infer<typeof archiveReferenceSchema>;
42
63
  export interface ArchiveChunk {
43
64
  id: string;
44
65
  entryId: string;
package/src/remote.ts ADDED
@@ -0,0 +1,166 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { defaultHome } from './agents';
4
+ import type { ArchiveChunk, ArchiveSnapshot } from './index';
5
+ import { archiveKey, archiveSnapshotSchema } from './index';
6
+ import type { ArchiveFilter, ArchiveSettings, TagDefinition } from './schemas';
7
+ import type { ArchiveReferenceCount, TagSuggestion } from './tags';
8
+
9
+ export type ArchiveListing = Omit<ArchiveSnapshot, 'entries'> & {
10
+ id: string;
11
+ revision: number;
12
+ digest: string;
13
+ entries: number;
14
+ models: { model: string; effort?: string; entries: number }[];
15
+ suggestedTags: TagSuggestion[];
16
+ references: ArchiveReferenceCount[];
17
+ };
18
+
19
+ export class ArchiveRequestError extends Error {
20
+ constructor(
21
+ readonly status: number,
22
+ message: string,
23
+ ) {
24
+ super(message);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * An Archive server over HTTP. Collectors, the CLI and Foundry write through this; only the
30
+ * server holds the database.
31
+ */
32
+ export class ArchiveClient {
33
+ private cachedSourceId?: string;
34
+ constructor(private readonly options: { url: string; token: string; fetch?: typeof fetch }) {}
35
+ async request<T>(action: string, body: unknown = {}): Promise<T> {
36
+ const response = await (this.options.fetch ?? fetch)(
37
+ new URL(`api/v1/archive/${action}`, this.options.url),
38
+ {
39
+ method: 'POST',
40
+ redirect: 'error',
41
+ signal: AbortSignal.timeout(30_000),
42
+ headers: {
43
+ authorization: `Bearer ${this.options.token}`,
44
+ 'content-type': 'application/json',
45
+ },
46
+ body: JSON.stringify(body),
47
+ },
48
+ );
49
+ const payload = (await response.json().catch(() => ({}))) as { data?: T; error?: string };
50
+ if (!response.ok)
51
+ throw new ArchiveRequestError(response.status, payload.error ?? `Archive ${action} failed`);
52
+ return payload.data as T;
53
+ }
54
+ async sourceId() {
55
+ this.cachedSourceId ??= (await this.request<{ sourceId: string }>('info')).sourceId;
56
+ return this.cachedSourceId;
57
+ }
58
+ head(id: string) {
59
+ return this.request<{ id: string; digest: string; revision: number; projectId: string | null }>(
60
+ 'head',
61
+ { archiveId: id },
62
+ ).catch((error) => {
63
+ if (error instanceof ArchiveRequestError && error.status === 404) return undefined;
64
+ throw error;
65
+ });
66
+ }
67
+ /** Captures a snapshot as the next revision, retrying when another writer got there first. */
68
+ async capture(input: unknown) {
69
+ const snapshot = archiveSnapshotSchema.parse(input);
70
+ const id = archiveKey(snapshot);
71
+ for (let attempt = 0; ; attempt++) {
72
+ const head = await this.head(id);
73
+ try {
74
+ return await this.request<{
75
+ id: string;
76
+ digest: string;
77
+ revision: number;
78
+ changed: boolean;
79
+ }>('ingest', { snapshot, previousDigest: head?.digest ?? null });
80
+ } catch (error) {
81
+ const conflict =
82
+ error instanceof ArchiveRequestError &&
83
+ error.status === 409 &&
84
+ error.message === 'Revision conflict';
85
+ if (!conflict || attempt >= 3) throw error;
86
+ }
87
+ }
88
+ }
89
+ read(id: string, revision?: number) {
90
+ return this.request<{
91
+ id: string;
92
+ revision: number;
93
+ digest: string;
94
+ snapshot: ArchiveSnapshot;
95
+ chunks: ArchiveChunk[];
96
+ }>('read', { archiveId: id, ...(revision ? { revision } : {}) }).catch((error) => {
97
+ if (error instanceof ArchiveRequestError && error.status === 404) return undefined;
98
+ throw error;
99
+ });
100
+ }
101
+ /** Every listing matching the filter, following cursors. */
102
+ async list(filter: ArchiveFilter = {}) {
103
+ const archives: ArchiveListing[] = [];
104
+ let beforeId: string | undefined;
105
+ do {
106
+ const page = await this.request<{ archives: ArchiveListing[]; nextCursor: string | null }>(
107
+ 'list',
108
+ { ...filter, limit: 100, ...(beforeId ? { beforeId } : {}) },
109
+ );
110
+ archives.push(...page.archives);
111
+ beforeId = page.nextCursor ?? undefined;
112
+ } while (beforeId);
113
+ return archives;
114
+ }
115
+ search(
116
+ body: ArchiveFilter & { query?: string; budget?: number; limit?: number; beforeId?: string },
117
+ ) {
118
+ return this.request<{
119
+ archives: (Omit<ArchiveListing, 'id' | 'entries'> & {
120
+ archiveId: string;
121
+ chunks: ArchiveChunk[];
122
+ tokenCount: number;
123
+ })[];
124
+ tokenCount: number;
125
+ nextCursor: string | null;
126
+ }>('search', body);
127
+ }
128
+ tag(id: string, change: { add?: string[]; remove?: string[] }) {
129
+ return this.request<{ id: string; tags: string[] }>('tag', { archiveId: id, ...change });
130
+ }
131
+ delete(id: string) {
132
+ return this.request<{ deleted: boolean }>('delete', { archiveId: id });
133
+ }
134
+ settings() {
135
+ return this.request<ArchiveSettings>('settings/read');
136
+ }
137
+ updateSettings(change: Partial<ArchiveSettings>) {
138
+ return this.request<ArchiveSettings>('settings/update', change);
139
+ }
140
+ tagDefinitions(actorId?: string) {
141
+ return this.request<{ tags: (TagDefinition & { archives: number })[] }>(
142
+ 'tags/list',
143
+ actorId ? { actorId } : {},
144
+ );
145
+ }
146
+ }
147
+
148
+ /** The machine's local Archive server (the `archive` block in @inixiative/config ports). */
149
+ export const DEFAULT_URL = 'http://127.0.0.1:4700';
150
+
151
+ /**
152
+ * A client for this machine's Archive: `ARCHIVE_URL` or the default address, with `ARCHIVE_TOKEN`
153
+ * or the home's `server.token`. Undefined until `archive up` (or `init`) has made a token.
154
+ */
155
+ export function localArchive(options: { home?: string; url?: string; fetch?: typeof fetch } = {}) {
156
+ const tokenFile = join(options.home ?? defaultHome(), 'server.token');
157
+ const token =
158
+ process.env.ARCHIVE_TOKEN ??
159
+ (existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : undefined);
160
+ if (!token) return undefined;
161
+ return new ArchiveClient({
162
+ url: options.url ?? process.env.ARCHIVE_URL ?? DEFAULT_URL,
163
+ token,
164
+ fetch: options.fetch,
165
+ });
166
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import type { ArchiveSnapshot } from './index';
3
+ import { archiveIntegrationSchema, defaultIntegrations } from './tags';
4
+
5
+ export const archiveSettingsSchema = z.strictObject({
6
+ /** Integrations sessions can reference; Kingdom and Foundry display these, Archive owns them. */
7
+ integrations: z
8
+ .array(archiveIntegrationSchema)
9
+ .max(50)
10
+ .refine(
11
+ (list) => new Set(list.map((i) => i.key)).size === list.length,
12
+ 'Duplicate integration keys',
13
+ ),
14
+ /** Archives whose latest capture is older than this are deleted here. Deletions never sync. */
15
+ retentionDays: z.number().int().positive().max(36_500).nullable(),
16
+ });
17
+ export type ArchiveSettings = z.infer<typeof archiveSettingsSchema>;
18
+ export const defaultSettings: ArchiveSettings = {
19
+ integrations: defaultIntegrations,
20
+ retentionDays: null,
21
+ };
22
+
23
+ export const tagSchema = z.string().min(1).max(120);
24
+ /** A conceptual tag this archive offers: archive-wide (no actor) or for one actor. */
25
+ export const tagDefinitionSchema = z.strictObject({
26
+ tag: tagSchema,
27
+ actorId: z.string().min(1).max(256).optional(),
28
+ description: z.string().max(500).optional(),
29
+ });
30
+ export type TagDefinition = z.infer<typeof tagDefinitionSchema>;
31
+
32
+ export interface ArchiveFilter {
33
+ projectId?: string;
34
+ source?: ArchiveSnapshot['source'];
35
+ tag?: string;
36
+ actorId?: string;
37
+ reference?: { integration: string; ref: string };
38
+ model?: string;
39
+ effort?: string;
40
+ }
package/src/server.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { timingSafeEqual } from 'node:crypto';
2
2
  import { z } from 'zod';
3
- import { archiveKey, archiveSnapshotSchema, selectChunks, snapshotDigest } from './index';
4
- import { LocalArchiveStore } from './local';
3
+ import { archiveReferenceSchema, archiveSnapshotSchema, selectChunks } from './index';
4
+ import { ArchiveConflict, ArchiveStore, archiveSettingsSchema, tagDefinitionSchema } from './store';
5
5
 
6
6
  const ingestSchema = z.strictObject({
7
7
  snapshot: archiveSnapshotSchema,
@@ -10,21 +10,35 @@ const ingestSchema = z.strictObject({
10
10
  .regex(/^[a-f0-9]{64}$/)
11
11
  .nullable(),
12
12
  });
13
+ const archiveId = z.string().regex(/^[a-f0-9]{64}$/);
14
+ const filterFields = {
15
+ projectId: z.string().max(256).optional(),
16
+ source: archiveSnapshotSchema.shape.source.optional(),
17
+ tag: z.string().min(1).max(120).optional(),
18
+ actorId: z.string().min(1).max(256).optional(),
19
+ reference: archiveReferenceSchema.optional(),
20
+ model: z.string().min(1).max(200).optional(),
21
+ effort: z.string().min(1).max(40).optional(),
22
+ limit: z.number().int().min(1).max(100).default(20),
23
+ beforeId: archiveId.optional(),
24
+ };
25
+ const listSchema = z.strictObject(filterFields);
13
26
  const searchSchema = z.strictObject({
27
+ ...filterFields,
14
28
  query: z.string().max(1000).default(''),
15
- tag: z.string().min(1).max(120).optional(),
16
29
  budget: z.number().int().min(16).max(32768).default(2048),
17
- projectId: z.string().max(256).optional(),
18
- limit: z.number().int().min(1).max(100).default(20),
19
- beforeId: z.string().optional(),
20
30
  });
21
- const readSchema = z.strictObject({
22
- archiveId: z.string().regex(/^[a-f0-9]{64}$/),
23
- revision: z.number().int().positive().optional(),
31
+ const readSchema = z.strictObject({ archiveId, revision: z.number().int().positive().optional() });
32
+ const tagSchema = z.strictObject({
33
+ archiveId,
34
+ add: z.array(z.string()).max(100).optional(),
35
+ remove: z.array(z.string()).max(100).optional(),
24
36
  });
37
+ const tagListSchema = z.strictObject({ actorId: z.string().min(1).max(256).optional() });
38
+ const tagRemoveSchema = tagDefinitionSchema.pick({ tag: true, actorId: true });
25
39
 
26
40
  /** One deployment, one ownership boundary. No shared cross-tenant database or admin API. */
27
- export function createArchiveHandler(store: LocalArchiveStore, token: string) {
41
+ export function createArchiveHandler(store: ArchiveStore, token: string) {
28
42
  if (token.length < 32)
29
43
  throw new Error('ARCHIVE_SERVER_TOKEN must contain at least 32 characters');
30
44
  const expected = Buffer.from(`Bearer ${token}`);
@@ -55,75 +69,102 @@ export function createArchiveHandler(store: LocalArchiveStore, token: string) {
55
69
  chunks.push(item.value);
56
70
  }
57
71
  const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
72
+ if (path === '/api/v1/archive/info') {
73
+ z.strictObject({}).parse(body);
74
+ return json({ data: { sourceId: await store.sourceId(), protocol: 2 } });
75
+ }
58
76
  if (path === '/api/v1/archive/ingest') {
59
77
  const { snapshot, previousDigest } = ingestSchema.parse(body);
60
- const id = archiveKey(snapshot),
61
- old = store.read(id),
62
- next = snapshotDigest(snapshot);
63
- if (old?.digest !== next && (old?.digest ?? null) !== previousDigest)
64
- return json({ error: 'Revision conflict' }, 409);
65
- if (old && old.digest !== next && old.snapshot.capturedAt > snapshot.capturedAt)
66
- return json({ error: 'Stale capture' }, 409);
67
- return json({ data: store.capture(snapshot) });
78
+ return json({ data: await store.capture(snapshot, { previousDigest }) });
79
+ }
80
+ if (path === '/api/v1/archive/head') {
81
+ const head = await store.head(readSchema.pick({ archiveId: true }).parse(body).archiveId);
82
+ return head ? json({ data: head }) : json({ error: 'Archive unavailable' }, 404);
68
83
  }
69
84
  if (path === '/api/v1/archive/read') {
70
85
  const input = readSchema.parse(body),
71
- archive = store.read(input.archiveId, input.revision);
86
+ archive = await store.read(input.archiveId, input.revision);
72
87
  return archive ? json({ data: archive }) : json({ error: 'Archive unavailable' }, 404);
73
88
  }
89
+ if (path === '/api/v1/archive/list') {
90
+ const { limit, beforeId, ...filter } = listSchema.parse(body);
91
+ const result = await store.page(filter, limit, beforeId);
92
+ if (!result) return json({ error: 'Invalid cursor' }, 400);
93
+ return json({ data: { archives: result.items, nextCursor: result.nextCursor } });
94
+ }
74
95
  if (path === '/api/v1/archive/search') {
75
- const input = searchSchema.parse(body);
76
- let remaining = input.budget;
77
- const matching = store
78
- .list()
79
- .filter(
80
- (a) =>
81
- (!input.projectId || a.projectId === input.projectId) &&
82
- (!input.tag || a.tags.includes(input.tag)),
83
- )
84
- .map((a) => ({
85
- a,
86
- selected: selectChunks(store.read(a.id)!.chunks, input.query, input.budget),
87
- }))
88
- .filter(
89
- ({ a, selected }) =>
90
- !input.query ||
91
- selected.matchingChunks > 0 ||
92
- [a.title, ...a.tags].some((text) =>
93
- text.toLowerCase().includes(input.query.toLowerCase()),
94
- ),
96
+ const { query, budget, limit, beforeId, ...filter } = searchSchema.parse(body);
97
+ const lower = query.toLowerCase();
98
+ const result = await store.page(filter, limit, beforeId, async (listings) => {
99
+ if (!query) return new Set(listings.map((a) => a.id));
100
+ const hits = await store.matching(
101
+ listings.map((a) => a.id),
102
+ query,
95
103
  );
96
- const cursor = input.beforeId ? matching.findIndex(({ a }) => a.id === input.beforeId) : -1;
97
- if (input.beforeId && cursor < 0) return json({ error: 'Invalid cursor' }, 400);
98
- const page = matching.slice(cursor + 1, cursor + 1 + input.limit);
99
- const archives = page.map(({ a }) => {
104
+ for (const a of listings)
105
+ if ([a.title, ...a.tags].some((text) => text.toLowerCase().includes(lower)))
106
+ hits.add(a.id);
107
+ return hits;
108
+ });
109
+ if (!result) return json({ error: 'Invalid cursor' }, 400);
110
+ let remaining = budget;
111
+ const archives = [];
112
+ for (const a of result.items) {
100
113
  const selected =
101
114
  remaining >= 16
102
- ? selectChunks(store.read(a.id)!.chunks, input.query, remaining)
115
+ ? selectChunks((await store.read(a.id))!.chunks, query, remaining)
103
116
  : { chunks: [], tokenCount: 0 };
104
117
  remaining -= selected.tokenCount;
105
- return {
118
+ archives.push({
106
119
  archiveId: a.id,
107
120
  revision: a.revision,
108
121
  digest: a.digest,
109
122
  title: a.title,
110
123
  projectId: a.projectId,
124
+ actor: a.actor,
125
+ models: a.models,
111
126
  tags: a.tags,
112
127
  suggestedTags: a.suggestedTags,
128
+ references: a.references,
113
129
  coverage: a.coverage,
114
130
  ...selected,
115
- };
116
- });
131
+ });
132
+ }
117
133
  return json({
118
- data: {
119
- archives,
120
- tokenCount: input.budget - remaining,
121
- nextCursor: matching.length > cursor + 1 + page.length ? page.at(-1)?.a.id : null,
122
- },
134
+ data: { archives, tokenCount: budget - remaining, nextCursor: result.nextCursor },
123
135
  });
124
136
  }
137
+ if (path === '/api/v1/archive/tag') {
138
+ const input = tagSchema.parse(body);
139
+ if (!(await store.read(input.archiveId)))
140
+ return json({ error: 'Archive unavailable' }, 404);
141
+ return json({ data: await store.tag(input.archiveId, input) });
142
+ }
143
+ if (path === '/api/v1/archive/delete') {
144
+ const input = readSchema.pick({ archiveId: true }).parse(body);
145
+ return (await store.delete(input.archiveId))
146
+ ? json({ data: { archiveId: input.archiveId, deleted: true } })
147
+ : json({ error: 'Archive unavailable' }, 404);
148
+ }
149
+ if (path === '/api/v1/archive/settings/read') {
150
+ z.strictObject({}).parse(body);
151
+ return json({ data: await store.settings() });
152
+ }
153
+ if (path === '/api/v1/archive/settings/update')
154
+ return json({
155
+ data: await store.updateSettings(archiveSettingsSchema.partial().parse(body)),
156
+ });
157
+ if (path === '/api/v1/archive/tags/list')
158
+ return json({
159
+ data: { tags: await store.tagDefinitions(tagListSchema.parse(body).actorId) },
160
+ });
161
+ if (path === '/api/v1/archive/tags/define')
162
+ return json({ data: await store.defineTag(tagDefinitionSchema.parse(body)) });
163
+ if (path === '/api/v1/archive/tags/remove')
164
+ return json({ data: { removed: await store.undefineTag(tagRemoveSchema.parse(body)) } });
125
165
  return json({ error: 'Not found' }, 404);
126
166
  } catch (error) {
167
+ if (error instanceof ArchiveConflict) return json({ error: error.message }, 409);
127
168
  if (error instanceof z.ZodError || error instanceof SyntaxError)
128
169
  return json({ error: 'Invalid archive request' }, 400);
129
170
  return json({ error: 'Archive operation failed' }, 500);
@@ -131,16 +172,20 @@ export function createArchiveHandler(store: LocalArchiveStore, token: string) {
131
172
  };
132
173
  }
133
174
 
134
- export function startArchiveServer(options: {
135
- store: string;
175
+ export async function startArchiveServer(options: {
176
+ databaseUrl: string;
136
177
  token: string;
137
178
  port?: number;
138
179
  hostname?: string;
139
180
  }) {
140
- const store = new LocalArchiveStore(options.store);
181
+ const store = new ArchiveStore(options.databaseUrl);
141
182
  try {
183
+ // Retention runs at start and hourly; it only ever deletes from this archive.
184
+ await store.prune();
185
+ const retention = setInterval(() => void store.prune().catch(() => {}), 3_600_000);
186
+ retention.unref();
142
187
  const server = Bun.serve({
143
- port: options.port ?? 4411,
188
+ port: options.port ?? 4700,
144
189
  hostname: options.hostname ?? '127.0.0.1',
145
190
  maxRequestBodySize: 65_000_000,
146
191
  fetch: createArchiveHandler(store, options.token),
@@ -149,12 +194,13 @@ export function startArchiveServer(options: {
149
194
  server,
150
195
  store,
151
196
  async close() {
197
+ clearInterval(retention);
152
198
  await server.stop(true);
153
- store.close();
199
+ await store.close();
154
200
  },
155
201
  };
156
202
  } catch (error) {
157
- store.close();
203
+ await store.close();
158
204
  throw error;
159
205
  }
160
206
  }