@inixiative/archive 0.12.1 → 0.14.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 +4 -4
- package/package.json +2 -2
- package/prisma/migrations/20261012120000_runtime/migration.sql +10 -0
- package/prisma/schema.prisma +5 -0
- package/src/agents.ts +98 -38
- package/src/cli.ts +108 -29
- package/src/client.ts +55 -2
- package/src/collector.ts +270 -27
- package/src/config.ts +6 -1
- package/src/import-file.ts +29 -2
- package/src/import.ts +134 -13
- package/src/index.ts +26 -0
- package/src/schemas.ts +2 -0
- package/src/server.ts +4 -0
- package/src/store.ts +4 -0
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ archive list
|
|
|
33
33
|
archive search --query 'migration'
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
Use `--source claude-code` for Claude histories. Collection matches the exact working-directory metadata recorded by the provider. Repeat `--project-root` to map several checkouts to one project; `--worktrees` also accepts every current git worktree of each root, re-read on each scan. Nested checkouts are separate mappings. Unknown directories, symlinks and sessions without usable metadata are skipped. Incomplete or changing files remain at source and retry on the next scan. The collector rescans
|
|
36
|
+
Use `--source claude-code` for Claude histories. Collection matches the exact working-directory metadata recorded by the provider. Repeat `--project-root` to map several checkouts to one project; `--worktrees` also accepts every current git worktree of each root, re-read on each scan. Nested checkouts are separate mappings unless `--subdirectories` lets a root also take the directories below it; the longest matching root wins. `--catch-all` (no roots) takes every session no other collector of that source claims, as projectId `unrouted`; no destination may take `unrouted`, so those sessions never leave the machine. Subdirectory and catch-all matches skip the exclusions: temp directories (test fixtures, agent scratchpads) and `.foundry/decision-receipts` by default, plus each `--exclude` (absolute paths match below them, relative ones match those path segments anywhere); an exact root is explicit and still matches. Unknown directories, symlinks and sessions without usable metadata are skipped. Incomplete or changing files remain at source and retry on the next scan. The collector rescans every 30 seconds. A session whose digest the server already holds is not sent again, and a watching collector does not re-read a file whose size and mtime have not moved since its last outcome (it re-checks a stored session against the server every 10 minutes, so one deleted there is captured again). A session is captured into one project; a collector refuses one another project holds, except one the catch-all holds as `unrouted`, which moves to the project that now claims it. Collectors compete only with collectors over the same history directory. A watching collector waits out a server that is still starting. `serve --sync` backs off failed deliveries: 30 seconds after the first failure, doubling to at most 30 minutes, cleared on delivery. An archive a destination refuses waits alone, and is tried again at once when it has a new revision; a destination that does not answer (or answers 5xx) is skipped for all its archives until it is due.
|
|
37
37
|
|
|
38
38
|
To run the server without Docker, point `DATABASE_URL` at a Postgres with the `vector` and `pg_trgm` extensions available and run `archive serve [--port 4700] [--sync]`. Set `ARCHIVE_DEBUG=1` to see the underlying error when a command fails.
|
|
39
39
|
|
|
@@ -62,7 +62,7 @@ archive agents install
|
|
|
62
62
|
archive agents status
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
`add-collector` upserts by `--name` (lowercase letters, digits, `.` and `-`); `--directory` defaults to `~/.claude/projects` or `~/.codex/sessions`, and `--project-root`, `--worktrees` and `--atlas` behave as for `collect`. `remove-collector --name N` and `server --url URL` edit the same file. `install` writes one unit per collector, `com.inixiative.archive.collect.<name
|
|
65
|
+
`add-collector` upserts by `--name` (lowercase letters, digits, `.` and `-`); `--directory` defaults to `~/.claude/projects` or `~/.codex/sessions`, and `--project-root`, `--worktrees`, `--subdirectories`, `--catch-all` and `--atlas` behave as for `collect`. `remove-collector --name N`, `exclude [--exclude PATH ...]` (replaces the extra exclusions) and `server --url URL` edit the same file. `install` writes one unit per collector, `com.inixiative.archive.collect.<name>`, running `collect --agent <name> --watch`: the unit re-reads `agents.json` on every scan and routes each session against every collector of its source, so edits to roots and exclusions apply without a reinstall. Each runs the current Bun with this package's own `src/cli.ts`, restarts on exit (30 second throttle), works in the archive home and logs to `logs/<label>.{out,err}.log` there. Re-running `install` reloads only changed or stopped units and removes Archive units no longer declared. Units are written and loaded one at a time; a unit that fails to load gets its previous file back, so the next `install` loads it again, and `install` reports every failure after trying the rest (including the retired `local` and `sync` units); other launchd agents are untouched. `uninstall` removes every Archive unit; `status` reports each unit's load state, PID and last log entry. Re-run `install` after upgrading the package if its install path changes.
|
|
66
66
|
|
|
67
67
|
`@inixiative/archive/agents` exports the pure pieces for integrations: `agentsConfigSchema`, `agentUnits`, `renderPlist`, `renderSystemdUnit` and `planAgents`, plus `installAgents`, `uninstallAgents` and `agentStatus` with an injectable command runner and supervisor directory.
|
|
68
68
|
|
|
@@ -128,7 +128,7 @@ The archive owns its tag system. It keeps two different things apart:
|
|
|
128
128
|
bun run archive tag --id ARCHIVE_ID --tag reviewed --untag debugging
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
Tag edits apply to that archive without a new revision, survive re-capture, and do not sync. Offered tags are defined archive-wide or for one actor. Each session can name its **actor**: a `user`, a `service` such as an API-key run, or a `job` (its id is the job name), with the `integrationId` it ran under when there is one. An organization's archive mostly holds its members' work, filterable per person. Entries the model produced record its `model` and `effort` (from Claude Code assistant records and Codex turn context, per turn); listings summarize them per session as `models` and filter by `model` and `effort`.
|
|
131
|
+
Tag edits apply to that archive without a new revision, survive re-capture, and do not sync. Offered tags are defined archive-wide or for one actor. Each session can name its **actor**: a `user`, a `service` such as an API-key run, or a `job` (its id is the job name), with the `integrationId` it ran under when there is one. An organization's archive mostly holds its members' work, filterable per person. Entries the model produced record its `model` and `effort` (from Claude Code assistant records and Codex turn context, per turn); listings summarize them per session as `models` and filter by `model` and `effort`. Each session also records its `runtime`: `provider` (`anthropic` for Claude Code, Codex's `model_provider`) and `accessPath` (`subscription`, `api` or `unknown`), with `evidence` `transcript` when the transcript proves it (a subscription's rate-limit window: Claude Code `quotaLimits`, Codex `rate_limits.plan_type`) or `harness` when the collector read the harness login instead (Claude Code's `oauthAccount` unless its settings supply an API key; Codex `auth.json`). A subagent run is its own session with `lineage`: `kind` (`subagent` or `fork`), `parentSessionId`, and what the harness recorded (`agentType`, `agentName`, `description`, `toolUseId`, `depth`; Claude Code's `agent-<id>.meta.json`, Codex's subagent `session_meta`). Example: `models: [{model: 'claude-opus-5-5', effort: 'medium'}]`, `runtime: {provider: 'anthropic', accessPath: 'subscription', evidence: 'harness'}`.
|
|
132
132
|
|
|
133
133
|
The **retention** setting (`retentionDays`, off by default) deletes archives whose latest capture is older than that, at server start and hourly. It deletes only from the archive it is set on; deletions never sync.
|
|
134
134
|
|
|
@@ -148,7 +148,7 @@ All data endpoints are `POST /api/v1/archive/<action>` with a JSON body and `Aut
|
|
|
148
148
|
| `tags/list`, `tags/define`, `tags/remove` | `tag`, `actorId?`, `description?` | offered tags with usage counts |
|
|
149
149
|
| `destinations/list`, `destinations/libraries`, `destinations/connect`, `destinations/remove`, `destinations/routes`, `destinations/sync` | see [Routing](#routing) | routes |
|
|
150
150
|
|
|
151
|
-
Filters are `projectId`, `source`, `tag`, `actorId`, `model`, `effort` and `reference` (`{integration, ref}`). Jev integration is not enabled. Session content must not be sent to Jev without selecting that service for the relevant ownership boundary.
|
|
151
|
+
Filters are `projectId`, `source`, `tag`, `actorId`, `model`, `effort`, `provider`, `accessPath` and `reference` (`{integration, ref}`); `archive list` and `archive search` take them as flags. Jev integration is not enabled. Session content must not be sent to Jev without selecting that service for the relevant ownership boundary.
|
|
152
152
|
|
|
153
153
|
## Limits
|
|
154
154
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/archive",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@biomejs/biome": "2.5.2",
|
|
46
|
-
"@inixiative/config": "^0.5.
|
|
46
|
+
"@inixiative/config": "^0.5.70",
|
|
47
47
|
"@types/bun": "1.4.2",
|
|
48
48
|
"lefthook": "2.1.9",
|
|
49
49
|
"typescript": "6.0.3"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- AlterTable
|
|
2
|
+
ALTER TABLE "archives" ADD COLUMN "accessPath" TEXT,
|
|
3
|
+
ADD COLUMN "provider" TEXT;
|
|
4
|
+
|
|
5
|
+
-- CreateIndex
|
|
6
|
+
CREATE INDEX "archives_provider_idx" ON "archives"("provider");
|
|
7
|
+
|
|
8
|
+
-- CreateIndex
|
|
9
|
+
CREATE INDEX "archives_accessPath_idx" ON "archives"("accessPath");
|
|
10
|
+
|
package/prisma/schema.prisma
CHANGED
|
@@ -28,6 +28,9 @@ model Archive {
|
|
|
28
28
|
source String
|
|
29
29
|
projectId String?
|
|
30
30
|
actorId String?
|
|
31
|
+
/// The model provider (anthropic, openai) and the access path (subscription, api, unknown).
|
|
32
|
+
provider String?
|
|
33
|
+
accessPath String?
|
|
31
34
|
capturedAt Float
|
|
32
35
|
summary Json
|
|
33
36
|
|
|
@@ -38,6 +41,8 @@ model Archive {
|
|
|
38
41
|
|
|
39
42
|
@@index([projectId])
|
|
40
43
|
@@index([actorId])
|
|
44
|
+
@@index([provider])
|
|
45
|
+
@@index([accessPath])
|
|
41
46
|
@@map("archives")
|
|
42
47
|
}
|
|
43
48
|
|
package/src/agents.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { homedir } from 'node:os';
|
|
|
17
17
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { z } from 'zod';
|
|
20
|
+
import { type CollectionSource, defaultExclusions, UNROUTED } from './collector';
|
|
20
21
|
|
|
21
22
|
export const defaultHome = () => join(homedir(), '.local/share/archive');
|
|
22
23
|
export const AGENT_LABEL_PREFIX = 'com.inixiative.archive.';
|
|
@@ -25,22 +26,40 @@ export const defaultHistoryDirectory = (source: 'claude-code' | 'codex') =>
|
|
|
25
26
|
join(homedir(), source === 'codex' ? '.codex/sessions' : '.claude/projects');
|
|
26
27
|
|
|
27
28
|
const absolute = z.string().max(4096).refine(isAbsolute, 'Path must be absolute');
|
|
28
|
-
export const collectorAgentSchema = z
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
29
|
+
export const collectorAgentSchema = z
|
|
30
|
+
.strictObject({
|
|
31
|
+
name: z
|
|
32
|
+
.string()
|
|
33
|
+
.max(64)
|
|
34
|
+
.regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/),
|
|
35
|
+
source: z.enum(['claude-code', 'codex']),
|
|
36
|
+
directory: absolute,
|
|
37
|
+
projectId: z.string().min(1).max(256),
|
|
38
|
+
projectRoots: z.array(absolute).max(100),
|
|
39
|
+
worktrees: z.boolean().optional(),
|
|
40
|
+
subdirectories: z.boolean().optional(),
|
|
41
|
+
/** Every session no other collector of this source claims; projectId `unrouted`, never synced. */
|
|
42
|
+
catchAll: z.boolean().optional(),
|
|
43
|
+
atlas: z.boolean().optional(),
|
|
44
|
+
})
|
|
45
|
+
.superRefine((c, ctx) => {
|
|
46
|
+
if (c.catchAll && (c.projectId !== UNROUTED || c.projectRoots.length))
|
|
47
|
+
ctx.addIssue({
|
|
48
|
+
code: 'custom',
|
|
49
|
+
message: `A catch-all collector has projectId ${UNROUTED} and no roots`,
|
|
50
|
+
});
|
|
51
|
+
if (!c.catchAll && (c.projectId === UNROUTED || !c.projectRoots.length))
|
|
52
|
+
ctx.addIssue({
|
|
53
|
+
code: 'custom',
|
|
54
|
+
message: `A collector needs a root and a projectId other than ${UNROUTED}`,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
40
57
|
export const agentsConfigSchema = z
|
|
41
58
|
.strictObject({
|
|
42
59
|
/** The Archive server collectors write to; the token is the home's `server.token`. */
|
|
43
60
|
server: z.strictObject({ url: z.url() }).optional(),
|
|
61
|
+
/** Added to the default exclusions (temp directories, Foundry decision receipts). */
|
|
62
|
+
exclude: z.array(z.string().min(1).max(4096)).max(100).optional(),
|
|
44
63
|
collectors: z.array(collectorAgentSchema).max(100),
|
|
45
64
|
})
|
|
46
65
|
.refine(
|
|
@@ -82,21 +101,36 @@ export interface AgentUnitOptions {
|
|
|
82
101
|
}
|
|
83
102
|
export const packageCliPath = () => fileURLToPath(new URL('./cli.ts', import.meta.url));
|
|
84
103
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
c.
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
/** A unit runs one named collector; it re-reads agents.json each scan, so edits apply live. */
|
|
105
|
+
export function collectorArgs(c: CollectorAgent) {
|
|
106
|
+
return ['collect', '--agent', c.name, '--watch'];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A named collector, the peers it is routed against, its exclusions and server, from agents.json. */
|
|
110
|
+
export function collectorPlan(config: AgentsConfig, name: string) {
|
|
111
|
+
const collector = config.collectors.find((c) => c.name === name);
|
|
112
|
+
if (!collector) throw new Error(`No collector named ${name} in agents.json`);
|
|
113
|
+
const source = (c: CollectorAgent): CollectionSource => ({
|
|
114
|
+
directory: c.directory,
|
|
115
|
+
source: c.source,
|
|
116
|
+
projectRoots: c.projectRoots,
|
|
117
|
+
projectId: c.projectId,
|
|
118
|
+
...(c.worktrees ? { worktrees: true } : {}),
|
|
119
|
+
...(c.subdirectories ? { subdirectories: true } : {}),
|
|
120
|
+
...(c.catchAll ? { catchAll: true } : {}),
|
|
121
|
+
...(c.atlas ? { atlas: true } : {}),
|
|
122
|
+
});
|
|
123
|
+
// Collectors over the same history compete for its sessions; another directory is another history.
|
|
124
|
+
const named = config.collectors.filter(
|
|
125
|
+
(c) => c.source === collector.source && resolve(c.directory) === resolve(collector.directory),
|
|
126
|
+
);
|
|
127
|
+
const peers = named.map(source);
|
|
128
|
+
return {
|
|
129
|
+
collector: peers[named.indexOf(collector)]!,
|
|
130
|
+
peers,
|
|
131
|
+
exclude: [...defaultExclusions(), ...(config.exclude ?? [])],
|
|
132
|
+
server: config.server,
|
|
133
|
+
};
|
|
100
134
|
}
|
|
101
135
|
|
|
102
136
|
/** One supervised unit per declared agent. */
|
|
@@ -114,7 +148,7 @@ export function agentUnits(config: AgentsConfig, options: AgentUnitOptions): Age
|
|
|
114
148
|
stderr: join(home, 'logs', `${label}.err.log`),
|
|
115
149
|
};
|
|
116
150
|
};
|
|
117
|
-
return config.collectors.map((c) => unit(`collect.${c.name}`, collectorArgs(c
|
|
151
|
+
return config.collectors.map((c) => unit(`collect.${c.name}`, collectorArgs(c)));
|
|
118
152
|
}
|
|
119
153
|
|
|
120
154
|
const xml = (value: string) =>
|
|
@@ -310,23 +344,49 @@ export function installAgents(config: AgentsConfig, options: AgentHostOptions =
|
|
|
310
344
|
}));
|
|
311
345
|
const plan = planAgents(units, installed, h.platform, h.dir);
|
|
312
346
|
h.remove(plan.remove);
|
|
313
|
-
|
|
347
|
+
const previous = new Map(installed.map((i) => [i.label, i.content]));
|
|
348
|
+
const write = (file: string, content: string) => {
|
|
314
349
|
const temp = `${file}.${randomUUID()}.tmp`;
|
|
315
350
|
writeFileSync(temp, content, { mode: 0o644, flag: 'wx' });
|
|
316
351
|
renameSync(temp, file);
|
|
317
|
-
}
|
|
318
|
-
|
|
352
|
+
};
|
|
353
|
+
const loaded: string[] = [];
|
|
354
|
+
const failed: { label: string; error: string }[] = [];
|
|
355
|
+
// Write and load one unit at a time. A unit that fails to load gets its old file back (or none),
|
|
356
|
+
// so the next install sees it as changed and loads it, instead of trusting a stale running copy.
|
|
357
|
+
const install = (label: string, content?: string) => {
|
|
358
|
+
try {
|
|
359
|
+
if (content !== undefined) {
|
|
360
|
+
write(h.file(label), content);
|
|
361
|
+
if (h.platform === 'linux') h.systemctl('daemon-reload');
|
|
362
|
+
}
|
|
363
|
+
h.load(label);
|
|
364
|
+
loaded.push(label);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
failed.push({ label, error: error instanceof Error ? error.message : String(error) });
|
|
367
|
+
if (content === undefined) return;
|
|
368
|
+
const old = previous.get(label);
|
|
369
|
+
try {
|
|
370
|
+
if (old === undefined) rmSync(h.file(label), { force: true });
|
|
371
|
+
else write(h.file(label), old);
|
|
372
|
+
if (h.platform === 'linux') h.systemctl('daemon-reload');
|
|
373
|
+
// Loading stopped the unit that ran before; bring it back as it was.
|
|
374
|
+
if (old !== undefined) h.load(label);
|
|
375
|
+
} catch {}
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
for (const { label, content } of plan.write) install(label, content);
|
|
319
379
|
// Unchanged units that are not running are loaded again.
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
380
|
+
for (const label of plan.unchanged) if (!h.loaded(label).loaded) install(label);
|
|
381
|
+
if (failed.length)
|
|
382
|
+
throw new Error(
|
|
383
|
+
`Archive agents failed to load: ${failed.map((f) => `${f.label} (${f.error})`).join('; ')}`,
|
|
384
|
+
);
|
|
325
385
|
return {
|
|
326
386
|
platform: h.platform,
|
|
327
387
|
dir: h.dir,
|
|
328
|
-
loaded
|
|
329
|
-
unchanged: plan.unchanged.filter((l) => !
|
|
388
|
+
loaded,
|
|
389
|
+
unchanged: plan.unchanged.filter((l) => !loaded.includes(l)),
|
|
330
390
|
removed: plan.remove,
|
|
331
391
|
};
|
|
332
392
|
}
|
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { parseArgs } from 'node:util';
|
|
8
8
|
import {
|
|
9
9
|
agentStatus,
|
|
10
|
+
collectorPlan,
|
|
10
11
|
defaultHistoryDirectory,
|
|
11
12
|
defaultHome,
|
|
12
13
|
installAgents,
|
|
@@ -16,8 +17,8 @@ import {
|
|
|
16
17
|
writeAgents,
|
|
17
18
|
} from './agents';
|
|
18
19
|
import { importChatGPTThread } from './chatgpt';
|
|
19
|
-
import { archiveRequest, searchRemotes, syncArchives } from './client';
|
|
20
|
-
import { collectSessions } from './collector';
|
|
20
|
+
import { archiveRequest, type SyncBackoff, searchRemotes, syncArchives } from './client';
|
|
21
|
+
import { type CollectorCache, collectSessions, defaultExclusions, UNROUTED } from './collector';
|
|
21
22
|
import {
|
|
22
23
|
archiveDestinationSchema,
|
|
23
24
|
connectDestination,
|
|
@@ -66,6 +67,14 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
66
67
|
query: { type: 'string', default: '' },
|
|
67
68
|
'project-root': { type: 'string', multiple: true },
|
|
68
69
|
worktrees: { type: 'boolean', default: false },
|
|
70
|
+
subdirectories: { type: 'boolean', default: false },
|
|
71
|
+
'catch-all': { type: 'boolean', default: false },
|
|
72
|
+
exclude: { type: 'string', multiple: true },
|
|
73
|
+
agent: { type: 'string' },
|
|
74
|
+
provider: { type: 'string' },
|
|
75
|
+
model: { type: 'string' },
|
|
76
|
+
effort: { type: 'string' },
|
|
77
|
+
'access-path': { type: 'string' },
|
|
69
78
|
atlas: { type: 'boolean', default: false },
|
|
70
79
|
tag: { type: 'string', multiple: true },
|
|
71
80
|
untag: { type: 'string', multiple: true },
|
|
@@ -91,14 +100,16 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
91
100
|
'up | down (the local Archive: bundled compose.yaml with Postgres, data in --home)\n' +
|
|
92
101
|
'serve [--port 4700 --hostname 127.0.0.1] [--sync] (DATABASE_URL; token from ARCHIVE_SERVER_TOKEN or --token-file)\n' +
|
|
93
102
|
'import --file PATH --source codex|claude-code|chatgpt --project-id ID [--tag TAG]\n' +
|
|
94
|
-
'collect --directory HISTORY --source codex|claude-code --project-root
|
|
95
|
-
'
|
|
103
|
+
'collect --directory HISTORY --source codex|claude-code (--project-root CWD [--project-root ...] --project-id ID [--worktrees] [--subdirectories] | --catch-all) [--exclude PATH ...] [--atlas] [--watch]\n' +
|
|
104
|
+
'collect --agent NAME [--watch] (one collector from agents.json, routed against the others, re-read each scan)\n' +
|
|
105
|
+
'list | search --query TEXT [--remote] [--project-id ID] [--source S] [--tag T] [--provider P] [--model M] [--effort E] [--access-path subscription|api|unknown]\n' +
|
|
106
|
+
'export --id ID | tag --id ID [--tag TAG ...] [--untag TAG ...]\n' +
|
|
96
107
|
'pair --kingdom KINGDOM_API_URL [--name NAME] [--yes] (register this Archive with Kingdom; claim the review code there, then confirm the owner)\n' +
|
|
97
108
|
'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) (a remote Archive, for serve --sync)\n' +
|
|
98
109
|
'connect --kind kingdom --url KINGDOM_API_URL --credential-file PATH --integration-id UUID --resource-id UUID --project-id ID (a hosted Archive through Kingdom)\n' +
|
|
99
110
|
'sync | routes (the server publishes to, or previews, its destinations once)\n' +
|
|
100
|
-
'agents add-collector --name N --source codex|claude-code --project-id ID --project-root DIR [--project-root ...] [--
|
|
101
|
-
'agents remove-collector --name N | server --url URL | install | uninstall | status\n' +
|
|
111
|
+
'agents add-collector --name N --source codex|claude-code (--project-id ID --project-root DIR [--project-root ...] [--worktrees] [--subdirectories] | --catch-all) [--directory HISTORY] [--atlas]\n' +
|
|
112
|
+
'agents remove-collector --name N | exclude [--exclude PATH ...] | server --url URL | install | uninstall | status\n' +
|
|
102
113
|
`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.`,
|
|
103
114
|
);
|
|
104
115
|
return;
|
|
@@ -170,10 +181,12 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
170
181
|
output({ listening: instance.server.url.href, sync: v.sync });
|
|
171
182
|
let stopped = false;
|
|
172
183
|
// One process owns the database connection: the server and its sync share it.
|
|
184
|
+
// Failed deliveries back off across cycles instead of being re-sent every 30 seconds.
|
|
185
|
+
const backoff: SyncBackoff = new Map();
|
|
173
186
|
const sync = (async () => {
|
|
174
187
|
while (v.sync && !stopped) {
|
|
175
188
|
try {
|
|
176
|
-
const results = await syncArchives(instance.store, readDestinations(config));
|
|
189
|
+
const results = await syncArchives(instance.store, readDestinations(config), { backoff });
|
|
177
190
|
const failed = results.filter((r) => r.status.startsWith('failed'));
|
|
178
191
|
if (failed.length) console.error(JSON.stringify({ sync: 'failed', archives: failed }));
|
|
179
192
|
} catch {
|
|
@@ -258,8 +271,11 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
258
271
|
const action = positionals[1];
|
|
259
272
|
const base = { collectors: [], ...current };
|
|
260
273
|
if (action === 'add-collector') {
|
|
261
|
-
|
|
262
|
-
|
|
274
|
+
const catchAll = v['catch-all'];
|
|
275
|
+
if (!v.name || !v.source || (!catchAll && (!v['project-id'] || !v['project-root'])))
|
|
276
|
+
throw new Error(
|
|
277
|
+
'add-collector requires --name, --source, and --project-id with --project-root (or --catch-all)',
|
|
278
|
+
);
|
|
263
279
|
const source = v.source as 'codex' | 'claude-code';
|
|
264
280
|
output(
|
|
265
281
|
writeAgents(
|
|
@@ -268,13 +284,18 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
268
284
|
name: v.name,
|
|
269
285
|
source,
|
|
270
286
|
directory: resolve(v.directory ?? defaultHistoryDirectory(source)),
|
|
271
|
-
projectId: v['project-id']
|
|
272
|
-
projectRoots: v['project-root']
|
|
287
|
+
projectId: catchAll ? UNROUTED : v['project-id']!,
|
|
288
|
+
projectRoots: catchAll ? [] : v['project-root']!.map((root) => resolve(root)),
|
|
273
289
|
...(v.worktrees ? { worktrees: true } : {}),
|
|
290
|
+
...(v.subdirectories ? { subdirectories: true } : {}),
|
|
291
|
+
...(catchAll ? { catchAll: true } : {}),
|
|
274
292
|
...(v.atlas ? { atlas: true } : {}),
|
|
275
293
|
}),
|
|
276
294
|
),
|
|
277
295
|
);
|
|
296
|
+
} else if (action === 'exclude') {
|
|
297
|
+
const { exclude: _, ...rest } = base;
|
|
298
|
+
output(writeAgents(file, { ...rest, ...(v.exclude?.length ? { exclude: v.exclude } : {}) }));
|
|
278
299
|
} else if (action === 'remove-collector') {
|
|
279
300
|
if (!v.name) throw new Error('remove-collector requires --name');
|
|
280
301
|
output(
|
|
@@ -313,7 +334,21 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
313
334
|
}
|
|
314
335
|
const token = process.env.ARCHIVE_TOKEN ?? readToken();
|
|
315
336
|
if (!token) throw new Error('No Archive token: run `archive init` or set ARCHIVE_TOKEN');
|
|
337
|
+
const agentsFile = join(home, 'agents.json');
|
|
316
338
|
const archive = new ArchiveClient({ url: v.url ?? DEFAULT_URL, token });
|
|
339
|
+
if ((command === 'list' || command === 'search') && (v.tag?.length ?? 0) > 1)
|
|
340
|
+
throw new Error('Filter by one --tag at a time');
|
|
341
|
+
const filter = {
|
|
342
|
+
...(v['project-id'] ? { projectId: v['project-id'] } : {}),
|
|
343
|
+
...(v.source ? { source: v.source as 'claude-code' | 'codex' | 'foundry' | 'chatgpt' } : {}),
|
|
344
|
+
...(v.tag?.length ? { tag: v.tag[0] } : {}),
|
|
345
|
+
...(v.provider ? { provider: v.provider } : {}),
|
|
346
|
+
...(v.model ? { model: v.model } : {}),
|
|
347
|
+
...(v.effort ? { effort: v.effort } : {}),
|
|
348
|
+
...(v['access-path']
|
|
349
|
+
? { accessPath: v['access-path'] as 'subscription' | 'api' | 'unknown' }
|
|
350
|
+
: {}),
|
|
351
|
+
};
|
|
317
352
|
if (command === 'routes') output(await archive.routes());
|
|
318
353
|
else if (command === 'sync') {
|
|
319
354
|
const results = await archive.sync();
|
|
@@ -349,15 +384,48 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
349
384
|
entries: snapshot.entries.length,
|
|
350
385
|
coverage: snapshot.coverage,
|
|
351
386
|
});
|
|
352
|
-
} else if (command === 'list') output(await archive.list());
|
|
387
|
+
} else if (command === 'list') output(await archive.list(filter));
|
|
353
388
|
else if (command === 'collect') {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
389
|
+
const flagged = () => {
|
|
390
|
+
const catchAll = v['catch-all'];
|
|
391
|
+
if (
|
|
392
|
+
!v.directory ||
|
|
393
|
+
!['codex', 'claude-code'].includes(v.source ?? '') ||
|
|
394
|
+
(!catchAll && (!v['project-root'] || !v['project-id']))
|
|
395
|
+
)
|
|
396
|
+
throw new Error(
|
|
397
|
+
'Collect requires --agent, or --directory, --source and --project-root with --project-id (or --catch-all)',
|
|
398
|
+
);
|
|
399
|
+
const collector = {
|
|
400
|
+
directory: v.directory,
|
|
401
|
+
source: v.source as 'codex' | 'claude-code',
|
|
402
|
+
projectRoots: catchAll ? [] : v['project-root']!,
|
|
403
|
+
projectId: catchAll ? UNROUTED : v['project-id']!,
|
|
404
|
+
worktrees: v.worktrees,
|
|
405
|
+
subdirectories: v.subdirectories,
|
|
406
|
+
catchAll,
|
|
407
|
+
atlas: v.atlas,
|
|
408
|
+
tags: v.tag,
|
|
409
|
+
};
|
|
410
|
+
return {
|
|
411
|
+
collector,
|
|
412
|
+
peers: [collector],
|
|
413
|
+
exclude: [...defaultExclusions(), ...(v.exclude ?? [])],
|
|
414
|
+
server: undefined as { url: string } | undefined,
|
|
415
|
+
};
|
|
416
|
+
};
|
|
417
|
+
// A named collector re-reads agents.json each scan, so its routing follows edits without a restart.
|
|
418
|
+
const plan = () => {
|
|
419
|
+
if (!v.agent) return flagged();
|
|
420
|
+
const config = readAgents(agentsFile);
|
|
421
|
+
if (!config) throw new Error('No agents.json; declare agents first');
|
|
422
|
+
return collectorPlan(config, v.agent);
|
|
423
|
+
};
|
|
424
|
+
plan();
|
|
425
|
+
const cache: CollectorCache = new Map();
|
|
426
|
+
// A named collector writes to the server agents.json declares (unless --url), re-read each scan.
|
|
427
|
+
let client = archive;
|
|
428
|
+
let clientUrl = v.url ?? DEFAULT_URL;
|
|
361
429
|
let stopped = false;
|
|
362
430
|
const stop = () => {
|
|
363
431
|
stopped = true;
|
|
@@ -365,17 +433,27 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
365
433
|
process.once('SIGINT', stop);
|
|
366
434
|
process.once('SIGTERM', stop);
|
|
367
435
|
do {
|
|
436
|
+
let current: ReturnType<typeof plan>;
|
|
437
|
+
try {
|
|
438
|
+
current = plan();
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (!v.watch) throw error;
|
|
441
|
+
console.error(
|
|
442
|
+
JSON.stringify({ collect: 'agents.json unusable', error: (error as Error).message }),
|
|
443
|
+
);
|
|
444
|
+
for (let i = 0; !stopped && i < 30; i++) await Bun.sleep(1000);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
368
447
|
try {
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
});
|
|
378
|
-
output(result);
|
|
448
|
+
const { collector, peers, exclude, server } = current;
|
|
449
|
+
const url = v.url ?? server?.url ?? DEFAULT_URL;
|
|
450
|
+
if (url !== clientUrl) {
|
|
451
|
+
client = new ArchiveClient({ url, token });
|
|
452
|
+
clientUrl = url;
|
|
453
|
+
}
|
|
454
|
+
const started = Date.now();
|
|
455
|
+
const result = await collectSessions(client, collector, { peers, exclude, cache });
|
|
456
|
+
output({ ...result, ms: Date.now() - started });
|
|
379
457
|
if (!v.watch && result.failed) process.exitCode = 1;
|
|
380
458
|
} catch (error) {
|
|
381
459
|
// The server may be starting; a watching collector retries.
|
|
@@ -390,6 +468,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
390
468
|
} else if (command === 'search')
|
|
391
469
|
output(
|
|
392
470
|
await archive.search({
|
|
471
|
+
...filter,
|
|
393
472
|
query: v.query,
|
|
394
473
|
limit: Number(v.limit) > 100 ? 100 : Number(v.limit),
|
|
395
474
|
}),
|
package/src/client.ts
CHANGED
|
@@ -126,28 +126,81 @@ export async function routingPreview(
|
|
|
126
126
|
}));
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Deliveries that failed, and when each may be tried again: per archive and destination, and per
|
|
131
|
+
* destination while it is unreachable. A retry waits 30 seconds, doubling per failure up to 30
|
|
132
|
+
* minutes; a new revision of the archive is tried at once.
|
|
133
|
+
*/
|
|
134
|
+
export type SyncBackoff = Map<string, { failures: number; until: number; digest?: string }>;
|
|
135
|
+
const BACKOFF_BASE_MS = 30_000;
|
|
136
|
+
const BACKOFF_MAX_MS = 30 * 60_000;
|
|
137
|
+
/** No HTTP answer, or a 5xx: the destination is down, not refusing this archive. */
|
|
138
|
+
const outage = (message: string) => {
|
|
139
|
+
const status = Number(/\((\d{3})\)/.exec(message)?.[1]);
|
|
140
|
+
return !status || status >= 500;
|
|
141
|
+
};
|
|
142
|
+
|
|
129
143
|
export async function syncArchives(
|
|
130
144
|
store: ArchiveStore,
|
|
131
145
|
destinations: ArchiveDestination[],
|
|
146
|
+
options: { backoff?: SyncBackoff; now?: () => number } = {},
|
|
132
147
|
): Promise<SyncResult[]> {
|
|
148
|
+
const now = options.now ?? Date.now;
|
|
149
|
+
const backoff = options.backoff;
|
|
133
150
|
const results: SyncResult[] = [];
|
|
134
|
-
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
const delay = (failures: number) =>
|
|
153
|
+
Math.min(BACKOFF_BASE_MS * 2 ** (failures - 1), BACKOFF_MAX_MS);
|
|
154
|
+
const archives = await store.list();
|
|
155
|
+
for (const archive of archives)
|
|
135
156
|
for (const destination of destinations.filter((d) => d.projectId === archive.projectId)) {
|
|
157
|
+
const identity = destinationIdentity(destination);
|
|
158
|
+
const key = JSON.stringify([archive.id, identity]);
|
|
159
|
+
const down = JSON.stringify(['destination', identity]);
|
|
160
|
+
seen.add(key).add(down);
|
|
161
|
+
const deferred = (until: number) =>
|
|
162
|
+
results.push({
|
|
163
|
+
id: archive.id,
|
|
164
|
+
destination: destination.url,
|
|
165
|
+
status: `deferred until ${new Date(until).toISOString()}`,
|
|
166
|
+
});
|
|
167
|
+
const circuit = backoff?.get(down);
|
|
168
|
+
if (circuit && now() < circuit.until) {
|
|
169
|
+
deferred(circuit.until);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const waiting = backoff?.get(key);
|
|
173
|
+
if (waiting && now() < waiting.until && waiting.digest === archive.digest) {
|
|
174
|
+
deferred(waiting.until);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
136
177
|
try {
|
|
137
178
|
const result = await publishArchive(store, archive.id, destination);
|
|
179
|
+
backoff?.delete(key);
|
|
180
|
+
backoff?.delete(down);
|
|
138
181
|
results.push({
|
|
139
182
|
id: archive.id,
|
|
140
183
|
destination: destination.url,
|
|
141
184
|
status: result.unchanged ? 'unchanged' : 'published',
|
|
142
185
|
});
|
|
143
186
|
} catch (error) {
|
|
187
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
188
|
+
if (outage(message)) {
|
|
189
|
+
const failures = (circuit?.failures ?? 0) + 1;
|
|
190
|
+
backoff?.set(down, { failures, until: now() + delay(failures) });
|
|
191
|
+
} else {
|
|
192
|
+
const failures = (waiting?.failures ?? 0) + 1;
|
|
193
|
+
backoff?.set(key, { failures, until: now() + delay(failures), digest: archive.digest });
|
|
194
|
+
}
|
|
144
195
|
results.push({
|
|
145
196
|
id: archive.id,
|
|
146
197
|
destination: destination.url,
|
|
147
|
-
status: `failed; local revision retained: ${
|
|
198
|
+
status: `failed; local revision retained: ${message}`,
|
|
148
199
|
});
|
|
149
200
|
}
|
|
150
201
|
}
|
|
202
|
+
// Archives that were deleted, or routes that were removed, leave no entries behind.
|
|
203
|
+
for (const key of backoff?.keys() ?? []) if (!seen.has(key)) backoff?.delete(key);
|
|
151
204
|
return results;
|
|
152
205
|
}
|
|
153
206
|
|