@inixiative/archive 0.6.0 → 0.8.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 +51 -47
- package/compose.yaml +24 -3
- package/package.json +11 -5
- 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 +239 -180
- package/src/client.ts +79 -51
- package/src/collector.ts +11 -11
- package/src/config.ts +33 -44
- package/src/kingdom.ts +103 -0
- package/src/remote.ts +170 -0
- package/src/schemas.ts +40 -0
- package/src/server.ts +56 -63
- package/src/store.ts +438 -0
- package/src/local.ts +0 -355
package/src/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { kingdomUrl, SignetClient } from '@inixiative/signet';
|
|
1
2
|
import {
|
|
2
3
|
type ArchiveDestination,
|
|
3
4
|
archiveDestinationSchema,
|
|
@@ -5,37 +6,29 @@ import {
|
|
|
5
6
|
destinationToken,
|
|
6
7
|
destinationUrl,
|
|
7
8
|
} from './config';
|
|
8
|
-
import type {
|
|
9
|
+
import type { ArchiveStore } from './store';
|
|
9
10
|
|
|
10
11
|
export { type ArchiveDestination, archiveDestinationSchema } from './config';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Object.entries({ ownerModel, organizationId, spaceId, connectionId }).filter(
|
|
18
|
-
([, value]) => value !== undefined,
|
|
19
|
-
),
|
|
20
|
-
);
|
|
21
|
-
}
|
|
13
|
+
type DirectDestination = Extract<ArchiveDestination, { kind: 'archive' }>;
|
|
14
|
+
type KingdomDestination = Extract<ArchiveDestination, { kind: 'kingdom' }>;
|
|
15
|
+
type Ingested = { id: string; digest: string; revision: number; changed: boolean };
|
|
16
|
+
|
|
17
|
+
const sessionWriteTimeoutMs = 300_000;
|
|
22
18
|
|
|
23
19
|
export async function archiveRequest(
|
|
24
|
-
destination:
|
|
20
|
+
destination: DirectDestination,
|
|
25
21
|
action: string,
|
|
26
22
|
body: unknown,
|
|
27
23
|
transport: typeof fetch = fetch,
|
|
28
24
|
) {
|
|
29
25
|
const url = destinationUrl(destination.url);
|
|
30
26
|
const token = destinationToken(destination);
|
|
31
|
-
if (!token
|
|
32
|
-
|
|
33
|
-
const path =
|
|
34
|
-
destination.kind === 'kingdom' && destination.connectionId ? `remote/${action}` : action;
|
|
35
|
-
const response = await transport(new URL(`api/v1/archive/${path}`, url), {
|
|
27
|
+
if (!token) throw new Error('Archive server token unavailable');
|
|
28
|
+
const response = await transport(new URL(`api/v1/archive/${action}`, url), {
|
|
36
29
|
method: 'POST',
|
|
37
30
|
redirect: 'error',
|
|
38
|
-
signal: AbortSignal.timeout(30_000),
|
|
31
|
+
signal: AbortSignal.timeout(action === 'ingest' ? sessionWriteTimeoutMs : 30_000),
|
|
39
32
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
40
33
|
body: JSON.stringify(body),
|
|
41
34
|
});
|
|
@@ -44,49 +37,77 @@ export async function archiveRequest(
|
|
|
44
37
|
return response.json() as Promise<{ data: any }>;
|
|
45
38
|
}
|
|
46
39
|
|
|
40
|
+
const kingdomSignet = async (destination: KingdomDestination) => {
|
|
41
|
+
const signet = await SignetClient.fromFile(destination.credentialFile);
|
|
42
|
+
if (signet.url !== kingdomUrl(destination.url))
|
|
43
|
+
throw new Error('The Signet credential belongs to a different Kingdom');
|
|
44
|
+
return signet;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Runs one Archive operation on the destination's library through the paired Signet. */
|
|
48
|
+
export async function kingdomArchiveOperation(
|
|
49
|
+
destination: KingdomDestination,
|
|
50
|
+
operation: 'sessions.write' | 'documents.search',
|
|
51
|
+
input: Record<string, unknown>,
|
|
52
|
+
) {
|
|
53
|
+
const { result } = await (await kingdomSignet(destination)).execute(
|
|
54
|
+
{
|
|
55
|
+
integrationId: destination.integrationId,
|
|
56
|
+
operation,
|
|
57
|
+
input: { resourceId: destination.resourceId, limit: 20, ...input },
|
|
58
|
+
},
|
|
59
|
+
{ timeoutMs: operation === 'sessions.write' ? sessionWriteTimeoutMs : 30_000 },
|
|
60
|
+
);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ingest = async (
|
|
65
|
+
destination: ArchiveDestination,
|
|
66
|
+
body: { snapshot: unknown; previousDigest: string | null },
|
|
67
|
+
transport: typeof fetch,
|
|
68
|
+
) =>
|
|
69
|
+
(destination.kind === 'archive'
|
|
70
|
+
? (await archiveRequest(destination, 'ingest', body, transport)).data
|
|
71
|
+
: await kingdomArchiveOperation(destination, 'sessions.write', body)) as Ingested | undefined;
|
|
72
|
+
|
|
47
73
|
export async function publishArchive(
|
|
48
|
-
store:
|
|
74
|
+
store: ArchiveStore,
|
|
49
75
|
id: string,
|
|
50
76
|
input: ArchiveDestination,
|
|
51
77
|
transport: typeof fetch = fetch,
|
|
52
78
|
) {
|
|
53
79
|
const destination = archiveDestinationSchema.parse(input);
|
|
54
|
-
const archive = store.
|
|
55
|
-
if (!archive || archive.
|
|
80
|
+
const archive = await store.head(id);
|
|
81
|
+
if (!archive || archive.projectId !== destination.projectId)
|
|
56
82
|
throw new Error('Archive is outside destination project');
|
|
57
83
|
const receiptKey = destinationIdentity(destination);
|
|
58
84
|
let sent = false;
|
|
59
85
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
60
|
-
const latest = store.
|
|
61
|
-
const previousDigest = store.receipt(id, receiptKey);
|
|
62
|
-
let pending = store.pending(id, receiptKey);
|
|
86
|
+
const latest = (await store.head(id))!;
|
|
87
|
+
const previousDigest = await store.receipt(id, receiptKey);
|
|
88
|
+
let pending = await store.pending(id, receiptKey);
|
|
63
89
|
if (!pending) {
|
|
64
90
|
if (previousDigest === latest.digest) return { unchanged: !sent };
|
|
65
|
-
store.enqueue(id, receiptKey, { revision: latest.revision });
|
|
66
|
-
pending = store.pending(id, receiptKey)!;
|
|
91
|
+
await store.enqueue(id, receiptKey, { revision: latest.revision });
|
|
92
|
+
pending = (await store.pending(id, receiptKey))!;
|
|
67
93
|
}
|
|
68
|
-
const queued = store.read(id, pending.revision);
|
|
94
|
+
const queued = await store.read(id, pending.revision);
|
|
69
95
|
if (!queued || queued.snapshot.projectId !== destination.projectId)
|
|
70
96
|
throw new Error('Pending archive is outside destination project');
|
|
71
|
-
const
|
|
97
|
+
const acknowledged = await ingest(
|
|
72
98
|
destination,
|
|
73
|
-
|
|
74
|
-
{
|
|
75
|
-
...kingdomFields(destination),
|
|
76
|
-
previousDigest,
|
|
77
|
-
snapshot: queued.snapshot,
|
|
78
|
-
},
|
|
99
|
+
{ previousDigest, snapshot: queued.snapshot },
|
|
79
100
|
transport,
|
|
80
101
|
);
|
|
81
|
-
if (
|
|
82
|
-
store.delivered(id, receiptKey, queued.digest);
|
|
102
|
+
if (acknowledged?.digest !== queued.digest) throw new Error('Archive acknowledgement mismatch');
|
|
103
|
+
await store.delivered(id, receiptKey, queued.digest);
|
|
83
104
|
sent = true;
|
|
84
105
|
}
|
|
85
106
|
throw new Error('Archive changed repeatedly during publication; retry sync');
|
|
86
107
|
}
|
|
87
108
|
|
|
88
|
-
export function routingPreview(store:
|
|
89
|
-
return store.list().map((archive) => ({
|
|
109
|
+
export async function routingPreview(store: ArchiveStore, destinations: ArchiveDestination[]) {
|
|
110
|
+
return (await store.list()).map((archive) => ({
|
|
90
111
|
id: archive.id,
|
|
91
112
|
projectId: archive.projectId,
|
|
92
113
|
tags: archive.tags,
|
|
@@ -96,14 +117,14 @@ export function routingPreview(store: LocalArchiveStore, destinations: ArchiveDe
|
|
|
96
117
|
.map((d) => ({
|
|
97
118
|
kind: d.kind,
|
|
98
119
|
url: d.url,
|
|
99
|
-
...
|
|
120
|
+
...(d.kind === 'kingdom' ? { integrationId: d.integrationId } : {}),
|
|
100
121
|
})),
|
|
101
122
|
}));
|
|
102
123
|
}
|
|
103
124
|
|
|
104
|
-
export async function syncArchives(store:
|
|
125
|
+
export async function syncArchives(store: ArchiveStore, destinations: ArchiveDestination[]) {
|
|
105
126
|
const results: { id: string; destination: string; status: string }[] = [];
|
|
106
|
-
for (const archive of store.list())
|
|
127
|
+
for (const archive of await store.list())
|
|
107
128
|
for (const destination of destinations.filter((d) => d.projectId === archive.projectId)) {
|
|
108
129
|
try {
|
|
109
130
|
const result = await publishArchive(store, archive.id, destination);
|
|
@@ -112,11 +133,11 @@ export async function syncArchives(store: LocalArchiveStore, destinations: Archi
|
|
|
112
133
|
destination: destination.url,
|
|
113
134
|
status: result.unchanged ? 'unchanged' : 'published',
|
|
114
135
|
});
|
|
115
|
-
} catch {
|
|
136
|
+
} catch (error) {
|
|
116
137
|
results.push({
|
|
117
138
|
id: archive.id,
|
|
118
139
|
destination: destination.url,
|
|
119
|
-
status:
|
|
140
|
+
status: `failed; local revision retained: ${error instanceof Error ? error.message : String(error)}`,
|
|
120
141
|
});
|
|
121
142
|
}
|
|
122
143
|
}
|
|
@@ -131,17 +152,24 @@ export async function searchRemotes(
|
|
|
131
152
|
return Promise.all(
|
|
132
153
|
destinations.map(async (destination) => {
|
|
133
154
|
try {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
155
|
+
const data =
|
|
156
|
+
destination.kind === 'archive'
|
|
157
|
+
? (
|
|
158
|
+
await archiveRequest(destination, 'search', {
|
|
159
|
+
query,
|
|
160
|
+
budget,
|
|
161
|
+
projectId: destination.projectId,
|
|
162
|
+
})
|
|
163
|
+
).data
|
|
164
|
+
: await kingdomArchiveOperation(destination, 'documents.search', {
|
|
165
|
+
query,
|
|
166
|
+
budget,
|
|
167
|
+
projectId: destination.projectId,
|
|
168
|
+
});
|
|
141
169
|
return {
|
|
142
170
|
destination: destination.url,
|
|
143
171
|
projectId: destination.projectId,
|
|
144
|
-
data
|
|
172
|
+
data,
|
|
145
173
|
};
|
|
146
174
|
} catch {
|
|
147
175
|
return {
|
package/src/collector.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { closeSync, existsSync, lstatSync, openSync, readdirSync, readSync } fro
|
|
|
2
2
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
3
|
import { importTranscriptFile } from './import-file';
|
|
4
4
|
import { archiveKey } from './index';
|
|
5
|
-
import type {
|
|
5
|
+
import type { ArchiveClient } from './remote';
|
|
6
6
|
import { type AtlasFileConcepts, atlasTags, provenanceReferences } from './tags';
|
|
7
7
|
|
|
8
8
|
export interface CollectionSource {
|
|
@@ -100,7 +100,11 @@ export function collectionRoots(config: CollectionSource): Set<string> {
|
|
|
100
100
|
return new Set(config.worktrees ? [...roots, ...roots.flatMap(worktreesOf)] : roots);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
/** Where collected sessions go: an Archive server, or a store in-process. */
|
|
104
|
+
export type ArchiveWriter = Pick<ArchiveClient, 'sourceId' | 'head' | 'capture'>;
|
|
105
|
+
|
|
106
|
+
export async function collectSessions(archive: ArchiveWriter, config: CollectionSource) {
|
|
107
|
+
const sourceId = await archive.sourceId();
|
|
104
108
|
const roots = collectionRoots(config);
|
|
105
109
|
const result = { imported: 0, unchanged: 0, skipped: 0, failed: 0 };
|
|
106
110
|
// Per scan: checkouts change between scans.
|
|
@@ -117,7 +121,7 @@ export async function collectSessions(store: LocalArchiveStore, config: Collecti
|
|
|
117
121
|
const before = lstatSync(file);
|
|
118
122
|
const snapshot = importTranscriptFile(file, {
|
|
119
123
|
source: config.source,
|
|
120
|
-
sourceId
|
|
124
|
+
sourceId,
|
|
121
125
|
projectId: config.projectId,
|
|
122
126
|
});
|
|
123
127
|
const after = lstatSync(file);
|
|
@@ -125,8 +129,8 @@ export async function collectSessions(store: LocalArchiveStore, config: Collecti
|
|
|
125
129
|
result.skipped++;
|
|
126
130
|
continue;
|
|
127
131
|
}
|
|
128
|
-
const old =
|
|
129
|
-
if (old && old.
|
|
132
|
+
const old = await archive.head(archiveKey(snapshot));
|
|
133
|
+
if (old && old.projectId !== config.projectId) {
|
|
130
134
|
result.failed++;
|
|
131
135
|
continue;
|
|
132
136
|
}
|
|
@@ -135,18 +139,14 @@ export async function collectSessions(store: LocalArchiveStore, config: Collecti
|
|
|
135
139
|
if (config.atlas && !graphs.has(cwd)) graphs.set(cwd, atlasGraph(cwd));
|
|
136
140
|
const graph = graphs.get(cwd);
|
|
137
141
|
snapshot.tags = [
|
|
138
|
-
...new Set([
|
|
139
|
-
...(old?.snapshot.tags ?? []),
|
|
140
|
-
...(config.tags ?? []),
|
|
141
|
-
...(graph ? atlasTags(snapshot, graph, roots) : []),
|
|
142
|
-
]),
|
|
142
|
+
...new Set([...(config.tags ?? []), ...(graph ? atlasTags(snapshot, graph, roots) : [])]),
|
|
143
143
|
].slice(0, 100);
|
|
144
144
|
const references = provenanceReferences({
|
|
145
145
|
branch: meta.branch,
|
|
146
146
|
repository: meta.repository ?? repositories.get(cwd),
|
|
147
147
|
});
|
|
148
148
|
if (references.length) snapshot.references = references;
|
|
149
|
-
if (
|
|
149
|
+
if ((await archive.capture(snapshot)).changed) result.imported++;
|
|
150
150
|
else result.unchanged++;
|
|
151
151
|
} catch {
|
|
152
152
|
result.failed++;
|
package/src/config.ts
CHANGED
|
@@ -14,43 +14,36 @@ import {
|
|
|
14
14
|
import { dirname, isAbsolute } from 'node:path';
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
.
|
|
22
|
-
|
|
23
|
-
.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
spaceId: z.uuid().optional(),
|
|
31
|
-
};
|
|
32
|
-
export const archiveDestinationSchema = z
|
|
33
|
-
.union([
|
|
34
|
-
z.strictObject({
|
|
35
|
-
...common,
|
|
36
|
-
kind: z.literal('archive'),
|
|
37
|
-
}),
|
|
38
|
-
z.strictObject({
|
|
39
|
-
...common,
|
|
40
|
-
kind: z.literal('kingdom'),
|
|
41
|
-
/** A hosted Archive that Kingdom forwards to; omitted, Kingdom stores the archive itself. */
|
|
42
|
-
connectionId: z
|
|
43
|
-
.string()
|
|
44
|
-
.regex(/^[a-z0-9-]+$/)
|
|
45
|
-
.max(120)
|
|
46
|
-
.optional(),
|
|
47
|
-
...kingdomOwnerFields,
|
|
48
|
-
}),
|
|
49
|
-
])
|
|
17
|
+
const projectId = z.string().min(1).max(256);
|
|
18
|
+
/** A remote Archive this one syncs to directly with its server token. */
|
|
19
|
+
const archiveDestination = z
|
|
20
|
+
.strictObject({
|
|
21
|
+
kind: z.literal('archive'),
|
|
22
|
+
projectId,
|
|
23
|
+
url: z.url(),
|
|
24
|
+
tokenEnv: z
|
|
25
|
+
.string()
|
|
26
|
+
.regex(/^[A-Z][A-Z0-9_]+$/)
|
|
27
|
+
.optional(),
|
|
28
|
+
tokenFile: z.string().max(4096).refine(isAbsolute, 'tokenFile must be absolute').optional(),
|
|
29
|
+
})
|
|
50
30
|
.refine(
|
|
51
31
|
(d) => Boolean(d.tokenEnv) !== Boolean(d.tokenFile),
|
|
52
32
|
'Set exactly one of tokenEnv or tokenFile',
|
|
53
33
|
);
|
|
34
|
+
/**
|
|
35
|
+
* A hosted Archive reached through Kingdom: this Archive presents the Signet it was paired with,
|
|
36
|
+
* granting sessions.write on that Archive's library.
|
|
37
|
+
*/
|
|
38
|
+
const kingdomDestination = z.strictObject({
|
|
39
|
+
kind: z.literal('kingdom'),
|
|
40
|
+
projectId,
|
|
41
|
+
url: z.url(),
|
|
42
|
+
credentialFile: z.string().max(4096).refine(isAbsolute, 'credentialFile must be absolute'),
|
|
43
|
+
integrationId: z.uuid(),
|
|
44
|
+
resourceId: z.uuid(),
|
|
45
|
+
});
|
|
46
|
+
export const archiveDestinationSchema = z.union([archiveDestination, kingdomDestination]);
|
|
54
47
|
export type ArchiveDestination = z.infer<typeof archiveDestinationSchema>;
|
|
55
48
|
|
|
56
49
|
/** Private regular file owned by this user; a symlink or group/world access is refused. */
|
|
@@ -65,7 +58,7 @@ export function readTokenFile(path: string) {
|
|
|
65
58
|
closeSync(fd);
|
|
66
59
|
}
|
|
67
60
|
}
|
|
68
|
-
export const destinationToken = (destination:
|
|
61
|
+
export const destinationToken = (destination: z.infer<typeof archiveDestination>) =>
|
|
69
62
|
destination.tokenFile ? readTokenFile(destination.tokenFile) : process.env[destination.tokenEnv!];
|
|
70
63
|
|
|
71
64
|
export function destinationUrl(input: string) {
|
|
@@ -93,18 +86,12 @@ export function readDestinations(file: string): ArchiveDestination[] {
|
|
|
93
86
|
}
|
|
94
87
|
export function destinationIdentity(destination: ArchiveDestination) {
|
|
95
88
|
const url = destinationUrl(destination.url);
|
|
96
|
-
// Preserve existing Foundry/Kingdom upload receipt identities.
|
|
97
89
|
return JSON.stringify([
|
|
98
90
|
url.origin,
|
|
99
91
|
new URL(destination.url).pathname,
|
|
100
92
|
destination.kind === 'archive'
|
|
101
93
|
? 'standalone'
|
|
102
|
-
: [
|
|
103
|
-
destination.connectionId ?? 'kingdom',
|
|
104
|
-
destination.ownerModel ?? null,
|
|
105
|
-
destination.organizationId ?? null,
|
|
106
|
-
destination.spaceId ?? null,
|
|
107
|
-
],
|
|
94
|
+
: ['kingdom', destination.integrationId, destination.resourceId],
|
|
108
95
|
]);
|
|
109
96
|
}
|
|
110
97
|
export function connectDestination(file: string, input: unknown) {
|
|
@@ -127,8 +114,10 @@ export function connectDestination(file: string, input: unknown) {
|
|
|
127
114
|
kind: destination.kind,
|
|
128
115
|
projectId: destination.projectId,
|
|
129
116
|
url: destination.url,
|
|
130
|
-
...(destination.
|
|
131
|
-
? {
|
|
132
|
-
:
|
|
117
|
+
...(destination.kind === 'kingdom'
|
|
118
|
+
? { integrationId: destination.integrationId, resourceId: destination.resourceId }
|
|
119
|
+
: destination.tokenFile
|
|
120
|
+
? { tokenFile: destination.tokenFile }
|
|
121
|
+
: { tokenEnv: destination.tokenEnv }),
|
|
133
122
|
};
|
|
134
123
|
}
|
package/src/kingdom.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
collectPairing,
|
|
5
|
+
generateSignetKey,
|
|
6
|
+
kingdomUrl,
|
|
7
|
+
requestPairing,
|
|
8
|
+
SignetClient,
|
|
9
|
+
saveCollectedSignet,
|
|
10
|
+
writePrivateJson,
|
|
11
|
+
} from '@inixiative/signet';
|
|
12
|
+
import type { ArchiveDestination } from './config';
|
|
13
|
+
|
|
14
|
+
const pollMs = 3000;
|
|
15
|
+
|
|
16
|
+
/** Libraries this Archive's Signet may write to, as Kingdom describes them. */
|
|
17
|
+
export async function writableLibraries(credentialFile: string) {
|
|
18
|
+
const description = await (await SignetClient.fromFile(credentialFile)).describe();
|
|
19
|
+
return (
|
|
20
|
+
description.operations
|
|
21
|
+
.find((operation) => operation.key === 'sessions.write')
|
|
22
|
+
?.resources.map((resource) => ({
|
|
23
|
+
integrationId: resource.integrationId,
|
|
24
|
+
resourceId: resource.id,
|
|
25
|
+
name: resource.name,
|
|
26
|
+
})) ?? []
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pairs this Archive with Kingdom as a local Archive integration bound to its sourceId. The owner
|
|
32
|
+
* approves the review code in Kingdom and chooses which hosted Archives it may write to.
|
|
33
|
+
*/
|
|
34
|
+
export async function pairWithKingdom(input: {
|
|
35
|
+
kingdom: string;
|
|
36
|
+
name: string;
|
|
37
|
+
sourceId: string;
|
|
38
|
+
directory: string;
|
|
39
|
+
onReview: (review: { reviewCode: string; review: string; expiresAt: string }) => void;
|
|
40
|
+
sleep?: (ms: number) => Promise<unknown>;
|
|
41
|
+
}) {
|
|
42
|
+
const kingdom = kingdomUrl(input.kingdom);
|
|
43
|
+
const keyFile = join(input.directory, `key-${crypto.randomUUID()}.json`);
|
|
44
|
+
await writePrivateJson(keyFile, generateSignetKey());
|
|
45
|
+
try {
|
|
46
|
+
return await awaitPairing(kingdom, keyFile, input);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
await rm(keyFile, { force: true });
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function awaitPairing(
|
|
54
|
+
kingdom: string,
|
|
55
|
+
keyFile: string,
|
|
56
|
+
input: Parameters<typeof pairWithKingdom>[0],
|
|
57
|
+
) {
|
|
58
|
+
const pending = await requestPairing(kingdom, keyFile, {
|
|
59
|
+
provider: 'archive',
|
|
60
|
+
deviceId: input.sourceId,
|
|
61
|
+
name: input.name,
|
|
62
|
+
resources: [],
|
|
63
|
+
expiresAt: null,
|
|
64
|
+
maxRequests: null,
|
|
65
|
+
maxConcurrent: 4,
|
|
66
|
+
});
|
|
67
|
+
input.onReview({
|
|
68
|
+
reviewCode: pending.reviewCode,
|
|
69
|
+
review: `${kingdom}/dashboard?reviewSignet=${pending.reviewCode}`,
|
|
70
|
+
expiresAt: pending.expiresAt,
|
|
71
|
+
});
|
|
72
|
+
const sleep = input.sleep ?? Bun.sleep;
|
|
73
|
+
while (Date.parse(pending.expiresAt) > Date.now()) {
|
|
74
|
+
const collected = await collectPairing(kingdom, keyFile, pending.deviceCode);
|
|
75
|
+
if (collected) {
|
|
76
|
+
const credentialFile = join(input.directory, `signet-${collected.signetId}.json`);
|
|
77
|
+
await saveCollectedSignet(credentialFile, kingdom, keyFile, collected);
|
|
78
|
+
return {
|
|
79
|
+
paired: true,
|
|
80
|
+
integrationId: collected.integrationId,
|
|
81
|
+
credentialFile,
|
|
82
|
+
libraries: await writableLibraries(credentialFile),
|
|
83
|
+
next: 'archive connect --kind kingdom --url KINGDOM --credential-file FILE --integration-id ID --resource-id ID --project-id PROJECT',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
await sleep(pollMs);
|
|
87
|
+
}
|
|
88
|
+
throw new Error('The review code expired before it was approved; run archive pair again');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function verifyKingdomDestination(
|
|
92
|
+
destination: Extract<ArchiveDestination, { kind: 'kingdom' }>,
|
|
93
|
+
) {
|
|
94
|
+
const libraries = await writableLibraries(destination.credentialFile);
|
|
95
|
+
if (
|
|
96
|
+
!libraries.some(
|
|
97
|
+
(library) =>
|
|
98
|
+
library.integrationId === destination.integrationId &&
|
|
99
|
+
library.resourceId === destination.resourceId,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
throw new Error("This Archive's Signet does not grant sessions.write on that library");
|
|
103
|
+
}
|
package/src/remote.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
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>(
|
|
36
|
+
action: string,
|
|
37
|
+
body: unknown = {},
|
|
38
|
+
{ timeoutMs = 30_000 }: { timeoutMs?: number } = {},
|
|
39
|
+
): Promise<T> {
|
|
40
|
+
const response = await (this.options.fetch ?? fetch)(
|
|
41
|
+
new URL(`api/v1/archive/${action}`, this.options.url),
|
|
42
|
+
{
|
|
43
|
+
method: 'POST',
|
|
44
|
+
redirect: 'error',
|
|
45
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
46
|
+
headers: {
|
|
47
|
+
authorization: `Bearer ${this.options.token}`,
|
|
48
|
+
'content-type': 'application/json',
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
const payload = (await response.json().catch(() => ({}))) as { data?: T; error?: string };
|
|
54
|
+
if (!response.ok)
|
|
55
|
+
throw new ArchiveRequestError(response.status, payload.error ?? `Archive ${action} failed`);
|
|
56
|
+
return payload.data as T;
|
|
57
|
+
}
|
|
58
|
+
async sourceId() {
|
|
59
|
+
this.cachedSourceId ??= (await this.request<{ sourceId: string }>('info')).sourceId;
|
|
60
|
+
return this.cachedSourceId;
|
|
61
|
+
}
|
|
62
|
+
head(id: string) {
|
|
63
|
+
return this.request<{ id: string; digest: string; revision: number; projectId: string | null }>(
|
|
64
|
+
'head',
|
|
65
|
+
{ archiveId: id },
|
|
66
|
+
).catch((error) => {
|
|
67
|
+
if (error instanceof ArchiveRequestError && error.status === 404) return undefined;
|
|
68
|
+
throw error;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Captures a snapshot as the next revision, retrying when another writer got there first. */
|
|
72
|
+
async capture(input: unknown) {
|
|
73
|
+
const snapshot = archiveSnapshotSchema.parse(input);
|
|
74
|
+
const id = archiveKey(snapshot);
|
|
75
|
+
for (let attempt = 0; ; attempt++) {
|
|
76
|
+
const head = await this.head(id);
|
|
77
|
+
try {
|
|
78
|
+
return await this.request<{
|
|
79
|
+
id: string;
|
|
80
|
+
digest: string;
|
|
81
|
+
revision: number;
|
|
82
|
+
changed: boolean;
|
|
83
|
+
}>('ingest', { snapshot, previousDigest: head?.digest ?? null });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const conflict =
|
|
86
|
+
error instanceof ArchiveRequestError &&
|
|
87
|
+
error.status === 409 &&
|
|
88
|
+
error.message === 'Revision conflict';
|
|
89
|
+
if (!conflict || attempt >= 3) throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
read(id: string, revision?: number) {
|
|
94
|
+
return this.request<{
|
|
95
|
+
id: string;
|
|
96
|
+
revision: number;
|
|
97
|
+
digest: string;
|
|
98
|
+
snapshot: ArchiveSnapshot;
|
|
99
|
+
chunks: ArchiveChunk[];
|
|
100
|
+
}>('read', { archiveId: id, ...(revision ? { revision } : {}) }).catch((error) => {
|
|
101
|
+
if (error instanceof ArchiveRequestError && error.status === 404) return undefined;
|
|
102
|
+
throw error;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** Every listing matching the filter, following cursors. */
|
|
106
|
+
async list(filter: ArchiveFilter = {}) {
|
|
107
|
+
const archives: ArchiveListing[] = [];
|
|
108
|
+
let beforeId: string | undefined;
|
|
109
|
+
do {
|
|
110
|
+
const page = await this.request<{ archives: ArchiveListing[]; nextCursor: string | null }>(
|
|
111
|
+
'list',
|
|
112
|
+
{ ...filter, limit: 100, ...(beforeId ? { beforeId } : {}) },
|
|
113
|
+
);
|
|
114
|
+
archives.push(...page.archives);
|
|
115
|
+
beforeId = page.nextCursor ?? undefined;
|
|
116
|
+
} while (beforeId);
|
|
117
|
+
return archives;
|
|
118
|
+
}
|
|
119
|
+
search(
|
|
120
|
+
body: ArchiveFilter & { query?: string; budget?: number; limit?: number; beforeId?: string },
|
|
121
|
+
) {
|
|
122
|
+
return this.request<{
|
|
123
|
+
archives: (Omit<ArchiveListing, 'id' | 'entries'> & {
|
|
124
|
+
archiveId: string;
|
|
125
|
+
chunks: ArchiveChunk[];
|
|
126
|
+
tokenCount: number;
|
|
127
|
+
})[];
|
|
128
|
+
tokenCount: number;
|
|
129
|
+
nextCursor: string | null;
|
|
130
|
+
}>('search', body);
|
|
131
|
+
}
|
|
132
|
+
tag(id: string, change: { add?: string[]; remove?: string[] }) {
|
|
133
|
+
return this.request<{ id: string; tags: string[] }>('tag', { archiveId: id, ...change });
|
|
134
|
+
}
|
|
135
|
+
delete(id: string) {
|
|
136
|
+
return this.request<{ deleted: boolean }>('delete', { archiveId: id });
|
|
137
|
+
}
|
|
138
|
+
settings() {
|
|
139
|
+
return this.request<ArchiveSettings>('settings/read');
|
|
140
|
+
}
|
|
141
|
+
updateSettings(change: Partial<ArchiveSettings>) {
|
|
142
|
+
return this.request<ArchiveSettings>('settings/update', change);
|
|
143
|
+
}
|
|
144
|
+
tagDefinitions(actorId?: string) {
|
|
145
|
+
return this.request<{ tags: (TagDefinition & { archives: number })[] }>(
|
|
146
|
+
'tags/list',
|
|
147
|
+
actorId ? { actorId } : {},
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The machine's local Archive server (the `archive` block in @inixiative/config ports). */
|
|
153
|
+
export const DEFAULT_URL = 'http://127.0.0.1:4700';
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A client for this machine's Archive: `ARCHIVE_URL` or the default address, with `ARCHIVE_TOKEN`
|
|
157
|
+
* or the home's `server.token`. Undefined until `archive up` (or `init`) has made a token.
|
|
158
|
+
*/
|
|
159
|
+
export function localArchive(options: { home?: string; url?: string; fetch?: typeof fetch } = {}) {
|
|
160
|
+
const tokenFile = join(options.home ?? defaultHome(), 'server.token');
|
|
161
|
+
const token =
|
|
162
|
+
process.env.ARCHIVE_TOKEN ??
|
|
163
|
+
(existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : undefined);
|
|
164
|
+
if (!token) return undefined;
|
|
165
|
+
return new ArchiveClient({
|
|
166
|
+
url: options.url ?? process.env.ARCHIVE_URL ?? DEFAULT_URL,
|
|
167
|
+
token,
|
|
168
|
+
fetch: options.fetch,
|
|
169
|
+
});
|
|
170
|
+
}
|