@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/README.md +61 -47
- package/compose.yaml +37 -0
- package/package.json +12 -6
- package/prisma/migrations/20261001022030_init/migration.sql +119 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +125 -0
- package/src/agents.ts +5 -15
- package/src/cli.ts +204 -149
- package/src/client.ts +15 -15
- package/src/collector.ts +17 -16
- package/src/import.ts +17 -0
- package/src/index.ts +21 -0
- package/src/remote.ts +166 -0
- package/src/schemas.ts +40 -0
- package/src/server.ts +104 -58
- package/src/store.ts +438 -0
- package/src/tags.ts +96 -36
- package/src/local.ts +0 -149
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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
23
|
-
|
|
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:
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
.
|
|
79
|
-
.
|
|
80
|
-
(a) =>
|
|
81
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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,
|
|
115
|
+
? selectChunks((await store.read(a.id))!.chunks, query, remaining)
|
|
103
116
|
: { chunks: [], tokenCount: 0 };
|
|
104
117
|
remaining -= selected.tokenCount;
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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
|
}
|