@inixiative/archive 0.6.0 → 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/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { parseArgs } from 'node:util';
6
7
  import {
7
8
  agentStatus,
@@ -17,7 +18,6 @@ import { importChatGPTThread } from './chatgpt';
17
18
  import {
18
19
  archiveRequest,
19
20
  kingdomFields,
20
- publishArchive,
21
21
  routingPreview,
22
22
  searchRemotes,
23
23
  syncArchives,
@@ -25,10 +25,20 @@ import {
25
25
  import { collectSessions } from './collector';
26
26
  import { archiveDestinationSchema, connectDestination, readDestinations } from './config';
27
27
  import { importTranscriptFile } from './import-file';
28
- import { archiveKey } from './index';
29
- import { LocalArchiveStore } from './local';
30
28
  import { previewImports } from './preview';
29
+ import { ArchiveClient, DEFAULT_URL } from './remote';
31
30
  import { startArchiveServer } from './server';
31
+ import { ArchiveStore } from './store';
32
+
33
+ /** Applies pending schema migrations to the archive database. */
34
+ export function migrate(databaseUrl: string) {
35
+ const schema = fileURLToPath(new URL('../prisma/schema.prisma', import.meta.url));
36
+ const run = Bun.spawnSync(
37
+ [process.execPath, 'x', 'prisma', 'migrate', 'deploy', '--schema', schema],
38
+ { env: { ...process.env, DATABASE_URL: databaseUrl }, stdout: 'ignore', stderr: 'pipe' },
39
+ );
40
+ if (!run.success) throw new Error(`Archive migration failed: ${run.stderr.toString().trim()}`);
41
+ }
32
42
 
33
43
  export async function runCli(args = Bun.argv.slice(2)) {
34
44
  const { values: v, positionals } = parseArgs({
@@ -36,7 +46,6 @@ export async function runCli(args = Bun.argv.slice(2)) {
36
46
  allowPositionals: true,
37
47
  options: {
38
48
  home: { type: 'string', default: defaultHome() },
39
- store: { type: 'string' },
40
49
  config: { type: 'string' },
41
50
  url: { type: 'string' },
42
51
  kind: { type: 'string', default: 'archive' },
@@ -66,55 +75,89 @@ export async function runCli(args = Bun.argv.slice(2)) {
66
75
  watch: { type: 'boolean', default: false },
67
76
  sync: { type: 'boolean', default: false },
68
77
  remote: { type: 'boolean', default: false },
69
- destination: { type: 'string' },
70
78
  help: { type: 'boolean' },
71
79
  },
72
80
  });
73
81
  const home = resolve(v.home!),
74
- config = v.config ?? join(home, 'destinations.json');
75
- const storePath = v.store ?? join(home, 'archives.sqlite');
82
+ config = v.config ?? join(home, 'destinations.json'),
83
+ tokenFile = v['token-file'] ? resolve(v['token-file']) : join(home, 'server.token');
76
84
  const output = (value: unknown) => console.log(JSON.stringify(value, null, 2));
77
85
  const command = positionals[0];
78
86
  if (v.help || !command) {
79
87
  console.log(
80
- 'Archive: init | serve | connect (setup) | preview | import | collect | list | export | tag | routes | sync | search | agents\n' +
81
- 'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) [--kind kingdom [--connection-id ID] [--owner-model M --organization-id UUID --space-id UUID]]\n' +
82
- 'import --file PATH --source codex|claude-code --project-id ID [--tag TAG]\n' +
83
- 'tag --id ID [--tag TAG ...] [--untag TAG ...]\n' +
88
+ 'Archive: init | up | down | serve | connect (setup) | preview | import | collect | list | export | tag | routes | sync | search | agents\n' +
89
+ 'up | down (the local Archive: bundled compose.yaml with Postgres, data in --home)\n' +
90
+ 'serve [--port 4700 --hostname 127.0.0.1] [--sync] (DATABASE_URL; token from ARCHIVE_SERVER_TOKEN or --token-file)\n' +
91
+ 'import --file PATH --source codex|claude-code|chatgpt --project-id ID [--tag TAG]\n' +
84
92
  'collect --directory HISTORY --source codex|claude-code --project-root EXACT_CWD [--project-root ...] [--worktrees] [--atlas] --project-id ID [--watch]\n' +
85
- 'sync [--watch] | routes | search --query TEXT [--remote]\n' +
86
- 'serve --home PATH [--port 4411 --hostname 127.0.0.1] [--sync]\n' +
93
+ 'list | export --id ID | tag --id ID [--tag TAG ...] [--untag TAG ...] | search --query TEXT [--remote]\n' +
94
+ 'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) [--kind kingdom ...] (destinations for serve --sync)\n' +
95
+ 'sync | routes (DATABASE_URL: publish to, or preview, destinations once)\n' +
87
96
  'agents add-collector --name N --source codex|claude-code --project-id ID --project-root DIR [--project-root ...] [--directory HISTORY] [--worktrees] [--atlas]\n' +
88
- 'agents remove-collector --name N | serve on|off [--port P] | sync on|off | install | uninstall | status\n' +
89
- 'All commands accept --home PATH; integrations may use --store FILE --config FILE.',
97
+ 'agents remove-collector --name N | server --url URL | install | uninstall | status\n' +
98
+ `Data commands talk to the Archive server at --url (default ${DEFAULT_URL}) with the token in --token-file (default <home>/server.token) or ARCHIVE_TOKEN.`,
90
99
  );
91
100
  return;
92
101
  }
93
- if (command === 'init') {
102
+ const readToken = () =>
103
+ existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : undefined;
104
+ const init = () => {
94
105
  mkdirSync(home, { recursive: true, mode: 0o700 });
95
- const tokenFile = join(home, 'server.token');
96
106
  if (!existsSync(tokenFile))
97
- writeFileSync(tokenFile, randomBytes(32).toString('hex') + '\n', { mode: 0o600, flag: 'wx' });
107
+ writeFileSync(tokenFile, `${randomBytes(32).toString('hex')}\n`, { mode: 0o600, flag: 'wx' });
98
108
  chmodSync(tokenFile, 0o600);
99
- const store = new LocalArchiveStore(storePath);
100
- store.close();
101
- output({ initialized: true, home, store: storePath, tokenFile });
109
+ };
110
+ if (command === 'init') {
111
+ init();
112
+ output({ initialized: true, home, tokenFile });
113
+ return;
114
+ }
115
+ if (command === 'up' || command === 'down') {
116
+ // The machine's one local Archive: the bundled compose file, with its data in the home.
117
+ if (command === 'up') init();
118
+ const compose = fileURLToPath(new URL('../compose.yaml', import.meta.url));
119
+ const run = Bun.spawnSync(
120
+ [
121
+ 'docker',
122
+ 'compose',
123
+ '--project-name',
124
+ 'archive',
125
+ '--file',
126
+ compose,
127
+ ...(command === 'up' ? ['up', '--detach', '--wait'] : ['down']),
128
+ ],
129
+ {
130
+ env: {
131
+ ...process.env,
132
+ ARCHIVE_DATA_DIR: home,
133
+ // Compose requires the variable even to stop; down never needs the real token.
134
+ ARCHIVE_SERVER_TOKEN: readToken() ?? 'x'.repeat(32),
135
+ },
136
+ stdout: 'inherit',
137
+ stderr: 'inherit',
138
+ },
139
+ );
140
+ if (!run.success) throw new Error(`docker compose ${command} failed`);
141
+ output(command === 'up' ? { running: DEFAULT_URL, home } : { stopped: true });
102
142
  return;
103
143
  }
144
+ const databaseUrl = () => {
145
+ const url = process.env.DATABASE_URL;
146
+ if (!url) throw new Error('DATABASE_URL is required');
147
+ return url;
148
+ };
104
149
  if (command === 'serve') {
105
- const tokenFile = v['token-file'] ?? join(home, 'server.token');
106
- const token =
107
- process.env.ARCHIVE_SERVER_TOKEN ??
108
- (existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : '');
109
- const instance = startArchiveServer({
110
- store: storePath,
111
- token,
112
- port: Number(v.port ?? process.env.PORT ?? 4411),
150
+ const url = databaseUrl();
151
+ migrate(url);
152
+ const instance = await startArchiveServer({
153
+ databaseUrl: url,
154
+ token: process.env.ARCHIVE_SERVER_TOKEN ?? readToken() ?? '',
155
+ port: Number(v.port ?? process.env.PORT ?? 4700),
113
156
  hostname: v.hostname,
114
157
  });
115
- output({ listening: instance.server.url.href, store: storePath, sync: v.sync });
158
+ output({ listening: instance.server.url.href, sync: v.sync });
116
159
  let stopped = false;
117
- // One process owns the store: the server and its sync share it, as in a container.
160
+ // One process owns the database connection: the server and its sync share it.
118
161
  const sync = (async () => {
119
162
  while (v.sync && !stopped) {
120
163
  try {
@@ -138,12 +181,12 @@ export async function runCli(args = Bun.argv.slice(2)) {
138
181
  return;
139
182
  }
140
183
  if (command === 'connect' || command === 'setup') {
141
- const tokenFile = v['token-file'] && resolve(v['token-file']);
184
+ const destinationTokenFile = v['token-file'] && resolve(v['token-file']);
142
185
  const destination = archiveDestinationSchema.parse({
143
186
  projectId: v['project-id'],
144
187
  url: v.url,
145
- tokenEnv: v['token-env'] ?? (tokenFile ? undefined : 'ARCHIVE_TOKEN'),
146
- tokenFile,
188
+ tokenEnv: v['token-env'] ?? (destinationTokenFile ? undefined : 'ARCHIVE_TOKEN'),
189
+ tokenFile: destinationTokenFile,
147
190
  kind: v.kind,
148
191
  ...(v.kind === 'kingdom'
149
192
  ? {
@@ -171,11 +214,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
171
214
  if (command === 'agents') {
172
215
  const file = join(home, 'agents.json');
173
216
  const current = readAgents(file);
174
- const [action, state] = positionals.slice(1);
175
- const toggle = () => {
176
- if (state !== 'on' && state !== 'off') throw new Error(`agents ${action} requires on|off`);
177
- return state === 'on';
178
- };
217
+ const action = positionals[1];
179
218
  const base = { collectors: [], ...current };
180
219
  if (action === 'add-collector') {
181
220
  if (!v.name || !v['project-id'] || !v['project-root'] || !v.source)
@@ -203,16 +242,10 @@ export async function runCli(args = Bun.argv.slice(2)) {
203
242
  collectors: base.collectors.filter((c) => c.name !== v.name),
204
243
  }),
205
244
  );
206
- } else if (action === 'serve') {
207
- const { serve: _, ...rest } = base;
208
- output(
209
- writeAgents(
210
- file,
211
- toggle() ? { ...rest, serve: v.port ? { port: Number(v.port) } : {} } : rest,
212
- ),
213
- );
214
- } else if (action === 'sync') output(writeAgents(file, { ...base, sync: toggle() }));
215
- else if (action === 'install') {
245
+ } else if (action === 'server') {
246
+ if (!v.url) throw new Error('agents server requires --url');
247
+ output(writeAgents(file, { ...base, server: { url: v.url } }));
248
+ } else if (action === 'install') {
216
249
  if (!current) throw new Error('No agents.json; declare agents first');
217
250
  output(installAgents(current, { home }));
218
251
  } else if (action === 'uninstall') output(uninstallAgents({ home }));
@@ -237,110 +270,118 @@ export async function runCli(args = Bun.argv.slice(2)) {
237
270
  if (results.some((r) => 'error' in r)) process.exitCode = 1;
238
271
  return;
239
272
  }
240
- const store = new LocalArchiveStore(storePath);
241
- try {
242
- if (command === 'import') {
243
- if (
244
- !v.file ||
245
- !['codex', 'claude-code', 'chatgpt'].includes(v.source ?? '') ||
246
- !v['project-id']
247
- )
248
- throw new Error(
249
- 'Import requires --file, --source codex|claude-code|chatgpt and --project-id',
250
- );
251
- const snapshot =
252
- v.source === 'chatgpt'
253
- ? importChatGPTThread(JSON.parse(readFileSync(v.file, 'utf8')), {
254
- sourceId: store.sourceId,
255
- projectId: v['project-id'],
256
- title: v.title,
257
- })
258
- : importTranscriptFile(v.file, {
259
- source: v.source as 'codex' | 'claude-code',
260
- sourceId: store.sourceId,
261
- projectId: v['project-id'],
262
- sessionId: v['session-id'],
263
- title: v.title,
264
- });
265
- snapshot.tags = [
266
- ...new Set([...(store.read(archiveKey(snapshot))?.snapshot.tags ?? []), ...(v.tag ?? [])]),
267
- ];
268
- output({
269
- ...store.capture(snapshot),
270
- entries: snapshot.entries.length,
271
- coverage: snapshot.coverage,
272
- });
273
- } else if (command === 'list') output(store.list());
274
- else if (command === 'routes') output(routingPreview(store, readDestinations(config)));
275
- else if (command === 'sync' || command === 'collect') {
276
- if (
277
- command === 'collect' &&
278
- (!v.directory ||
279
- !v['project-root'] ||
280
- !v['project-id'] ||
281
- !['codex', 'claude-code'].includes(v.source ?? ''))
282
- )
283
- throw new Error('Collect requires --directory, --source, --project-root and --project-id');
284
- let stopped = false;
285
- const stop = () => {
286
- stopped = true;
287
- };
288
- process.once('SIGINT', stop);
289
- process.once('SIGTERM', stop);
290
- do {
291
- if (command === 'collect') {
292
- const result = await collectSessions(store, {
293
- directory: v.directory!,
294
- source: v.source as 'codex' | 'claude-code',
295
- projectRoots: v['project-root']!,
296
- worktrees: v.worktrees,
297
- atlas: v.atlas,
298
- projectId: v['project-id']!,
299
- tags: v.tag,
300
- });
301
- output(result);
302
- if (!v.watch && result.failed) process.exitCode = 1;
303
- } else {
304
- const results = await syncArchives(store, readDestinations(config));
305
- output(results);
306
- if (!v.watch && results.some((r) => r.status.startsWith('failed'))) process.exitCode = 1;
307
- }
308
- if (v.watch && !stopped) await Bun.sleep(1000);
309
- // Short waits allow prompt shutdown; network requests have their own timeout.
310
- for (let i = 1; v.watch && !stopped && i < 30; i++) await Bun.sleep(1000);
311
- } while (v.watch && !stopped);
312
- process.removeListener('SIGINT', stop);
313
- process.removeListener('SIGTERM', stop);
314
- } else if (command === 'publish') {
315
- if (!v.id || !v.destination) throw new Error('Publish requires --id and --destination');
316
- output(
317
- await publishArchive(
318
- store,
319
- v.id,
320
- archiveDestinationSchema.parse(JSON.parse(readFileSync(v.destination, 'utf8'))),
321
- ),
322
- );
323
- } else if (command === 'search')
324
- output(store.search(v.id ? [v.id] : store.list().map((a) => a.id), v.query!));
325
- else if (command === 'export' || command === 'tag') {
326
- const archive = v.id ? store.read(v.id) : undefined;
327
- if (!archive) throw new Error('Valid --id required');
328
- if (command === 'export') output(archive.snapshot);
273
+ if (command === 'sync' || command === 'routes') {
274
+ const store = new ArchiveStore(databaseUrl());
275
+ try {
276
+ if (command === 'routes') output(await routingPreview(store, readDestinations(config)));
329
277
  else {
330
- if (!v.tag?.length && !v.untag?.length)
331
- throw new Error('Tag requires at least one --tag or --untag');
332
- output(store.tag(archive.id, { add: v.tag, remove: v.untag }));
278
+ const results = await syncArchives(store, readDestinations(config));
279
+ output(results);
280
+ if (results.some((r) => r.status.startsWith('failed'))) process.exitCode = 1;
333
281
  }
334
- } else throw new Error('Unknown command; use --help');
335
- } finally {
336
- store.close();
282
+ } finally {
283
+ await store.close();
284
+ }
285
+ return;
337
286
  }
287
+ const token = process.env.ARCHIVE_TOKEN ?? readToken();
288
+ if (!token) throw new Error('No Archive token: run `archive init` or set ARCHIVE_TOKEN');
289
+ const archive = new ArchiveClient({ url: v.url ?? DEFAULT_URL, token });
290
+ if (command === 'import') {
291
+ if (
292
+ !v.file ||
293
+ !['codex', 'claude-code', 'chatgpt'].includes(v.source ?? '') ||
294
+ !v['project-id']
295
+ )
296
+ throw new Error(
297
+ 'Import requires --file, --source codex|claude-code|chatgpt and --project-id',
298
+ );
299
+ const sourceId = await archive.sourceId();
300
+ const snapshot =
301
+ v.source === 'chatgpt'
302
+ ? importChatGPTThread(JSON.parse(readFileSync(v.file, 'utf8')), {
303
+ sourceId,
304
+ projectId: v['project-id'],
305
+ title: v.title,
306
+ })
307
+ : importTranscriptFile(v.file, {
308
+ source: v.source as 'codex' | 'claude-code',
309
+ sourceId,
310
+ projectId: v['project-id'],
311
+ sessionId: v['session-id'],
312
+ title: v.title,
313
+ });
314
+ snapshot.tags = [...new Set(v.tag ?? [])];
315
+ output({
316
+ ...(await archive.capture(snapshot)),
317
+ entries: snapshot.entries.length,
318
+ coverage: snapshot.coverage,
319
+ });
320
+ } else if (command === 'list') output(await archive.list());
321
+ else if (command === 'collect') {
322
+ if (
323
+ !v.directory ||
324
+ !v['project-root'] ||
325
+ !v['project-id'] ||
326
+ !['codex', 'claude-code'].includes(v.source ?? '')
327
+ )
328
+ throw new Error('Collect requires --directory, --source, --project-root and --project-id');
329
+ let stopped = false;
330
+ const stop = () => {
331
+ stopped = true;
332
+ };
333
+ process.once('SIGINT', stop);
334
+ process.once('SIGTERM', stop);
335
+ do {
336
+ try {
337
+ const result = await collectSessions(archive, {
338
+ directory: v.directory,
339
+ source: v.source as 'codex' | 'claude-code',
340
+ projectRoots: v['project-root'],
341
+ worktrees: v.worktrees,
342
+ atlas: v.atlas,
343
+ projectId: v['project-id'],
344
+ tags: v.tag,
345
+ });
346
+ output(result);
347
+ if (!v.watch && result.failed) process.exitCode = 1;
348
+ } catch (error) {
349
+ // The server may be starting; a watching collector retries.
350
+ if (!v.watch) throw error;
351
+ console.error(JSON.stringify({ collect: 'server unavailable' }));
352
+ }
353
+ // Short waits allow prompt shutdown; network requests have their own timeout.
354
+ for (let i = 0; v.watch && !stopped && i < 30; i++) await Bun.sleep(1000);
355
+ } while (v.watch && !stopped);
356
+ process.removeListener('SIGINT', stop);
357
+ process.removeListener('SIGTERM', stop);
358
+ } else if (command === 'search')
359
+ output(
360
+ await archive.search({
361
+ query: v.query,
362
+ limit: Number(v.limit) > 100 ? 100 : Number(v.limit),
363
+ }),
364
+ );
365
+ else if (command === 'export' || command === 'tag') {
366
+ if (!v.id) throw new Error('Valid --id required');
367
+ if (command === 'export') {
368
+ const read = await archive.read(v.id);
369
+ if (!read) throw new Error('Valid --id required');
370
+ output(read.snapshot);
371
+ } else {
372
+ if (!v.tag?.length && !v.untag?.length)
373
+ throw new Error('Tag requires at least one --tag or --untag');
374
+ output(await archive.tag(v.id, { add: v.tag, remove: v.untag }));
375
+ }
376
+ } else throw new Error('Unknown command; use --help');
338
377
  }
339
378
 
340
379
  if (import.meta.main)
341
- runCli().catch(() => {
380
+ runCli().catch((error) => {
342
381
  console.error(
343
- 'Archive command failed. Check arguments, destination access and local paths. Use --help for usage.',
382
+ process.env.ARCHIVE_DEBUG
383
+ ? error
384
+ : 'Archive command failed. Check arguments, server and destination access, and local paths. Use --help for usage. Set ARCHIVE_DEBUG=1 for details.',
344
385
  );
345
386
  process.exitCode = 1;
346
387
  });
package/src/client.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  destinationToken,
6
6
  destinationUrl,
7
7
  } from './config';
8
- import type { LocalArchiveStore } from './local';
8
+ import type { ArchiveStore } from './store';
9
9
 
10
10
  export { type ArchiveDestination, archiveDestinationSchema } from './config';
11
11
 
@@ -45,27 +45,27 @@ export async function archiveRequest(
45
45
  }
46
46
 
47
47
  export async function publishArchive(
48
- store: LocalArchiveStore,
48
+ store: ArchiveStore,
49
49
  id: string,
50
50
  input: ArchiveDestination,
51
51
  transport: typeof fetch = fetch,
52
52
  ) {
53
53
  const destination = archiveDestinationSchema.parse(input);
54
- const archive = store.read(id);
55
- if (!archive || archive.snapshot.projectId !== destination.projectId)
54
+ const archive = await store.head(id);
55
+ if (!archive || archive.projectId !== destination.projectId)
56
56
  throw new Error('Archive is outside destination project');
57
57
  const receiptKey = destinationIdentity(destination);
58
58
  let sent = false;
59
59
  for (let attempt = 0; attempt < 8; attempt++) {
60
- const latest = store.read(id)!;
61
- const previousDigest = store.receipt(id, receiptKey);
62
- let pending = store.pending(id, receiptKey);
60
+ const latest = (await store.head(id))!;
61
+ const previousDigest = await store.receipt(id, receiptKey);
62
+ let pending = await store.pending(id, receiptKey);
63
63
  if (!pending) {
64
64
  if (previousDigest === latest.digest) return { unchanged: !sent };
65
- store.enqueue(id, receiptKey, { revision: latest.revision });
66
- pending = store.pending(id, receiptKey)!;
65
+ await store.enqueue(id, receiptKey, { revision: latest.revision });
66
+ pending = (await store.pending(id, receiptKey))!;
67
67
  }
68
- const queued = store.read(id, pending.revision);
68
+ const queued = await store.read(id, pending.revision);
69
69
  if (!queued || queued.snapshot.projectId !== destination.projectId)
70
70
  throw new Error('Pending archive is outside destination project');
71
71
  const body = await archiveRequest(
@@ -79,14 +79,14 @@ export async function publishArchive(
79
79
  transport,
80
80
  );
81
81
  if (body.data?.digest !== queued.digest) throw new Error('Archive acknowledgement mismatch');
82
- store.delivered(id, receiptKey, queued.digest);
82
+ await store.delivered(id, receiptKey, queued.digest);
83
83
  sent = true;
84
84
  }
85
85
  throw new Error('Archive changed repeatedly during publication; retry sync');
86
86
  }
87
87
 
88
- export function routingPreview(store: LocalArchiveStore, destinations: ArchiveDestination[]) {
89
- return store.list().map((archive) => ({
88
+ export async function routingPreview(store: ArchiveStore, destinations: ArchiveDestination[]) {
89
+ return (await store.list()).map((archive) => ({
90
90
  id: archive.id,
91
91
  projectId: archive.projectId,
92
92
  tags: archive.tags,
@@ -101,9 +101,9 @@ export function routingPreview(store: LocalArchiveStore, destinations: ArchiveDe
101
101
  }));
102
102
  }
103
103
 
104
- export async function syncArchives(store: LocalArchiveStore, destinations: ArchiveDestination[]) {
104
+ export async function syncArchives(store: ArchiveStore, destinations: ArchiveDestination[]) {
105
105
  const results: { id: string; destination: string; status: string }[] = [];
106
- for (const archive of store.list())
106
+ for (const archive of await store.list())
107
107
  for (const destination of destinations.filter((d) => d.projectId === archive.projectId)) {
108
108
  try {
109
109
  const result = await publishArchive(store, archive.id, destination);
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 { LocalArchiveStore } from './local';
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
- export async function collectSessions(store: LocalArchiveStore, config: CollectionSource) {
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: store.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 = store.read(archiveKey(snapshot));
129
- if (old && old.snapshot.projectId !== config.projectId) {
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 (store.capture(snapshot).changed) result.imported++;
149
+ if ((await archive.capture(snapshot)).changed) result.imported++;
150
150
  else result.unchanged++;
151
151
  } catch {
152
152
  result.failed++;