@hydra-acp/archiver 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sam Magnuson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # hydra-acp-archiver
2
+
3
+ Sync extension for [hydra-acp](https://github.com/smagnuso/hydra-acp). Keeps your agent sessions in sync across the machines you work on by uploading session bundles to a shared backend (Google Drive, plain filesystem) after every turn and importing peers' bundles in the background.
4
+
5
+ Runs as a daemon-managed process, so sessions started on machine A become available on machine B without any manual export/import.
6
+
7
+ ## How it works
8
+
9
+ 1. **Startup cold sweep**: lists every session the daemon knows about and exports the cold ones — so installing the archiver on a machine with a pile of existing sessions backfills them all to the backend without the user having to touch each one. Hash-dedup keeps subsequent restarts cheap (already-uploaded sessions are skipped).
10
+ 2. **Live monitoring**: watches every live hydra session via the daemon's WebSocket. When a session emits `turn_complete`, the archiver schedules a debounced flush (default 5 s window).
11
+ 3. **On flush**: asks the daemon to `GET /v1/sessions/:id/export`, wraps the resulting `.hydra` bundle in a sync envelope (lineageId + hash + uploader info), and writes it to the backend keyed by `lineageId`.
12
+ 4. **Live → cold final flush**: when a session goes cold (daemon detaches), one last flush captures its latest state before the WS bridge tears down.
13
+ 5. **Pull loop**: on a separate timer (default 60 s), lists the backend, downloads any envelope newer than what it last saw, and POSTs the bundle to `/v1/sessions/import` with `replace: true`.
14
+ 6. **De-dup**: uses hydra's stable `lineageId` (it survives every export/import hop) plus a SHA-256 of the canonicalized bundle to skip uploads when content hasn't actually changed.
15
+
16
+ Conflict resolution is last-writer-wins by envelope `uploadedAt`. The envelope format leaves room for a future `activeOn` claim-lock; not in this release.
17
+
18
+ ## Install
19
+
20
+ From npm (recommended once published):
21
+
22
+ ```sh
23
+ npm install -g @hydra-acp/archiver
24
+ ```
25
+
26
+ Drops a `hydra-acp-archiver` binary on your PATH.
27
+
28
+ Or from source:
29
+
30
+ ```sh
31
+ git clone git@github.com:smagnuson/hydra-acp-archiver.git ~/dev/hydra-acp-archiver
32
+ cd ~/dev/hydra-acp-archiver
33
+ npm install
34
+ npm run build
35
+ ```
36
+
37
+ Register the extension with hydra:
38
+
39
+ ```sh
40
+ hydra-acp extensions add hydra-acp-archiver
41
+ ```
42
+
43
+ Or pointed at a local build:
44
+
45
+ ```sh
46
+ hydra-acp extensions add hydra-acp-archiver \
47
+ --command node \
48
+ --args ~/dev/hydra-acp-archiver/dist/index.js
49
+ ```
50
+
51
+ That writes the equivalent entry into `~/.hydra-acp/config.json`:
52
+
53
+ ```json
54
+ {
55
+ "extensions": {
56
+ "hydra-acp-archiver": {
57
+ "command": ["node"],
58
+ "args": ["/home/you/dev/hydra-acp-archiver/dist/index.js"],
59
+ "enabled": true
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ On `hydra-acp daemon start`, hydra spawns hydra-acp-archiver as a managed subprocess. Stdout/stderr land in `~/.hydra-acp/extensions/hydra-acp-archiver.log`.
66
+
67
+ ## First-time Google setup
68
+
69
+ The Google Drive backend uses OAuth 2.0 with the **`drive.file`** scope — the archiver can only see files it creates plus those you explicitly hand it via a picker. Your other Drive contents stay invisible to it.
70
+
71
+ You provide your own OAuth client (Google's terms make it impractical to ship a shared one):
72
+
73
+ 1. Go to <https://console.cloud.google.com/> and create or pick a project.
74
+ 2. Enable the **Google Drive API** for that project.
75
+ 3. Configure the **OAuth consent screen**. User type: **External**. Add your Google account under **Test Users**.
76
+ 4. **Credentials → Create credentials → OAuth client ID**. Application type: **Desktop app**.
77
+ 5. Download the resulting JSON and save it to `~/.hydra-acp/archiver-google-credentials.json` (or anywhere, and set `HYDRA_ACP_ARCHIVER_GOOGLE_CREDENTIALS`).
78
+ 6. Run:
79
+
80
+ ```sh
81
+ hydra-acp-archiver login
82
+ ```
83
+
84
+ Your browser opens to Google's consent screen. The "Google hasn't verified this app" interstitial is expected for an unverified personal-use client — click **Advanced → Go to (unsafe)** and approve. The redirect lands on a transient local server, the archiver writes `~/.hydra-acp/archiver-google-token.json` (mode 0600), and you're done.
85
+
86
+ After this, restart the daemon. The archiver process starts up, creates a `hydra-acp-archive/` folder in your Drive on first upload, and begins syncing.
87
+
88
+ ## Multi-machine setup
89
+
90
+ Repeat the same login flow on each machine that should sync, using the **same Google account**. Each machine gets its own refresh token; they all point at the same Drive folder.
91
+
92
+ If you want a different Drive folder per "team" or "context," set `HYDRA_ACP_ARCHIVER_DRIVE_FOLDER` on every participating machine to the same value.
93
+
94
+ ## Filesystem backend
95
+
96
+ Useful for testing locally, or for pointing at a folder that a separate sync tool (Syncthing, Dropbox client) already mirrors across your machines.
97
+
98
+ ```json
99
+ {
100
+ "extensions": {
101
+ "hydra-acp-archiver": {
102
+ "command": ["node"],
103
+ "args": ["/home/you/dev/hydra-acp-archiver/dist/index.js"],
104
+ "env": {
105
+ "HYDRA_ACP_ARCHIVER_BACKEND": "fs",
106
+ "HYDRA_ACP_ARCHIVER_FS_DIR": "/home/you/Sync/hydra-archive"
107
+ }
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ ## Environment variables
114
+
115
+ | Variable | Default | Notes |
116
+ | --- | --- | --- |
117
+ | `HYDRA_ACP_ARCHIVER_BACKEND` | `google-drive` | `google-drive` \| `fs` |
118
+ | `HYDRA_ACP_ARCHIVER_DRIVE_FOLDER` | `hydra-acp-archive` | Drive folder name |
119
+ | `HYDRA_ACP_ARCHIVER_FS_DIR` | `~/.hydra-acp/archive` | Used when backend is `fs` |
120
+ | `HYDRA_ACP_ARCHIVER_GOOGLE_CREDENTIALS` | `~/.hydra-acp/archiver-google-credentials.json` | OAuth client JSON from GCP |
121
+ | `HYDRA_ACP_ARCHIVER_CONFIG` | `~/.hydra-acp/archiver.config.js` | Rule file (optional) |
122
+ | `HYDRA_ACP_ARCHIVER_POLL_MS` | `2000` | Session discovery cadence |
123
+ | `HYDRA_ACP_ARCHIVER_DEBOUNCE_MS` | `5000` | Per-session upload debounce window |
124
+ | `HYDRA_ACP_ARCHIVER_PULL_MS` | `60000` | Backend list cadence |
125
+ | `DEBUG` | `false` | Verbose logging |
126
+
127
+ ## Rule (opt-out specific sessions)
128
+
129
+ By default the archiver uploads every live session. Drop a JS module at `~/.hydra-acp/archiver.config.js` (or `HYDRA_ACP_ARCHIVER_CONFIG`) to skip some:
130
+
131
+ ```js
132
+ // ~/.hydra-acp/archiver.config.js
133
+ export default function archive(ev) {
134
+ // ev.sessionId, ev.lineageId
135
+ // ev.meta.cwd, ev.meta.agentId, ev.meta.title
136
+
137
+ // Don't sync work in throwaway scratch dirs:
138
+ if (ev.meta.cwd?.startsWith("/tmp/")) {
139
+ return false;
140
+ }
141
+
142
+ // Don't sync sessions whose title is prefixed with "(local)":
143
+ if (ev.meta.title?.startsWith("(local)")) {
144
+ return false;
145
+ }
146
+
147
+ return true;
148
+ }
149
+ ```
150
+
151
+ Return `false` to skip an upload. Any other value (including `undefined`) archives. The rule reloads on SIGHUP, so you don't need to restart the daemon to change it.
152
+
153
+ ## On-disk state
154
+
155
+ - `~/.hydra-acp/archiver-state.json` — per-lineage cache of last uploaded hash + last seen remote upload, used for self-loop suppression. Safe to delete; archiver will rebuild it.
156
+ - `~/.hydra-acp/archiver-google-credentials.json` — OAuth client JSON you downloaded.
157
+ - `~/.hydra-acp/archiver-google-token.json` — refresh + access token (mode 0600). Re-run `hydra-acp-archiver login` to refresh.
158
+
159
+ ## Troubleshooting
160
+
161
+ - **`Missing HYDRA_ACP_TOKEN env var`** — you ran the archiver directly instead of via the daemon. Run it as a registered extension.
162
+ - **`No Google OAuth token at …`** — run `hydra-acp-archiver login` first.
163
+ - **`OAuth credentials file not found`** — follow the **First-time Google setup** steps to download the client JSON from GCP Console.
164
+ - **Files aren't appearing in Drive** — check `~/.hydra-acp/extensions/hydra-acp-archiver.log` for errors. Common: consent-screen test-user list doesn't include your Google account.
165
+ - **Two machines kept overwriting each other** — that's last-writer-wins working as designed if both are actively editing the same session. Avoid editing the same session on two machines simultaneously; one of them will lose its diff. A future `activeOn` claim-lock will close this gap.
166
+
167
+ ## Backends
168
+
169
+ Current: `google-drive`, `fs`. Adding a new one means implementing the `SyncBackend` interface in `src/backend/types.ts` and wiring it into `src/backend/factory.ts`.
@@ -0,0 +1,192 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { readFileSync } from "node:fs";
3
+ import { WebSocket } from "ws";
4
+ import { logger } from "../util/log.js";
5
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
6
+ import { ACP_PROTOCOL_VERSION, isNotification, isRequest, isResponse, } from "./protocol.js";
7
+ const log = logger("acp");
8
+ export class AcpAttach extends EventEmitter {
9
+ opts;
10
+ ws;
11
+ nextId = 1;
12
+ pending = new Map();
13
+ connected = false;
14
+ constructor(opts) {
15
+ super();
16
+ this.opts = opts;
17
+ }
18
+ get sessionId() {
19
+ return this.opts.sessionId;
20
+ }
21
+ get isConnected() {
22
+ return this.connected;
23
+ }
24
+ start() {
25
+ log.debug(`connecting ${this.opts.daemonWsUrl} for ${this.opts.sessionId}`);
26
+ const subprotocols = ["acp.v1", `hydra-acp-token.${this.opts.token}`];
27
+ let ws;
28
+ try {
29
+ ws = new WebSocket(this.opts.daemonWsUrl, subprotocols);
30
+ }
31
+ catch (err) {
32
+ this.emit("error", err);
33
+ return;
34
+ }
35
+ this.ws = ws;
36
+ ws.on("open", () => {
37
+ this.connected = true;
38
+ log.info(`ws open ${this.opts.sessionId}`);
39
+ void this.handshake()
40
+ .then(() => {
41
+ this.emit("open");
42
+ })
43
+ .catch((err) => {
44
+ this.emit("error", err);
45
+ try {
46
+ this.ws?.close();
47
+ }
48
+ catch {
49
+ void 0;
50
+ }
51
+ });
52
+ });
53
+ ws.on("message", (data, isBinary) => {
54
+ if (isBinary) {
55
+ return;
56
+ }
57
+ const text = data.toString("utf8");
58
+ try {
59
+ const parsed = JSON.parse(text);
60
+ this.onMessage(parsed);
61
+ }
62
+ catch (err) {
63
+ log.warn(`parse error on ${this.opts.sessionId}: ${err.message}; raw=${text.slice(0, 200)}`);
64
+ }
65
+ });
66
+ ws.on("error", (err) => {
67
+ log.warn(`ws error ${this.opts.sessionId}: ${err.message}`);
68
+ this.emit("error", err);
69
+ });
70
+ ws.on("close", (code, reason) => {
71
+ const hadError = code >= 4000 || code === 1006 || code === 1011;
72
+ const reasonText = reason.toString("utf8");
73
+ this.connected = false;
74
+ log.info(`ws closed ${this.opts.sessionId} code=${code}${reasonText ? ` reason=${reasonText}` : ""}`);
75
+ for (const [, p] of this.pending) {
76
+ p.reject(new Error("ws closed"));
77
+ }
78
+ this.pending.clear();
79
+ this.emit("close", { hadError });
80
+ });
81
+ }
82
+ stop() {
83
+ if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
84
+ try {
85
+ this.ws.close();
86
+ }
87
+ catch {
88
+ void 0;
89
+ }
90
+ }
91
+ }
92
+ async request(method, params) {
93
+ const id = this.nextId++;
94
+ const msg = {
95
+ jsonrpc: "2.0",
96
+ id,
97
+ method,
98
+ ...(params !== undefined ? { params } : {}),
99
+ };
100
+ this.write(msg);
101
+ return new Promise((resolve, reject) => {
102
+ this.pending.set(id, {
103
+ resolve: (resp) => {
104
+ if (resp.error) {
105
+ reject(new Error(`${resp.error.code}: ${resp.error.message}`));
106
+ }
107
+ else {
108
+ resolve(resp.result);
109
+ }
110
+ },
111
+ reject,
112
+ });
113
+ });
114
+ }
115
+ notify(method, params) {
116
+ const msg = {
117
+ jsonrpc: "2.0",
118
+ method,
119
+ ...(params !== undefined ? { params } : {}),
120
+ };
121
+ this.write(msg);
122
+ }
123
+ reply(id, result) {
124
+ const msg = { jsonrpc: "2.0", id, result };
125
+ this.write(msg);
126
+ }
127
+ replyError(id, code, message) {
128
+ const msg = {
129
+ jsonrpc: "2.0",
130
+ id,
131
+ error: { code, message },
132
+ };
133
+ this.write(msg);
134
+ }
135
+ async handshake() {
136
+ try {
137
+ await this.request("initialize", {
138
+ protocolVersion: ACP_PROTOCOL_VERSION,
139
+ clientCapabilities: {
140
+ fs: { readTextFile: false, writeTextFile: false },
141
+ terminal: false,
142
+ },
143
+ });
144
+ }
145
+ catch (err) {
146
+ log.warn(`initialize failed for ${this.opts.sessionId}: ${err.message}`);
147
+ }
148
+ try {
149
+ // historyPolicy: "none" — the archiver pulls full bundles via REST,
150
+ // it doesn't need history replayed over the WS attach.
151
+ const attachResult = await this.request("session/attach", {
152
+ sessionId: this.opts.sessionId,
153
+ historyPolicy: "none",
154
+ clientInfo: { name: "hydra-acp-archiver", version: pkg.version },
155
+ });
156
+ log.info(`attached ${this.opts.sessionId}${attachResult.replayed !== undefined
157
+ ? ` replayed=${attachResult.replayed}`
158
+ : ""}`);
159
+ }
160
+ catch (err) {
161
+ log.warn(`session/attach failed for ${this.opts.sessionId}: ${err.message}`);
162
+ throw err;
163
+ }
164
+ }
165
+ write(msg) {
166
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
167
+ log.warn(`drop write to closed ws: ${JSON.stringify(msg)}`);
168
+ return;
169
+ }
170
+ this.ws.send(JSON.stringify(msg));
171
+ }
172
+ onMessage(m) {
173
+ if (isResponse(m)) {
174
+ const p = this.pending.get(m.id);
175
+ if (p) {
176
+ this.pending.delete(m.id);
177
+ p.resolve(m);
178
+ }
179
+ else {
180
+ log.debug(`unmatched response id=${String(m.id)}`);
181
+ }
182
+ this.emit("response", m);
183
+ }
184
+ else if (isRequest(m)) {
185
+ this.emit("request", m);
186
+ }
187
+ else if (isNotification(m)) {
188
+ this.emit("notification", m);
189
+ }
190
+ }
191
+ }
192
+ //# sourceMappingURL=attach.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attach.js","sourceRoot":"","sources":["../../src/acp/attach.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ // ACP wire protocol version this extension speaks. Single source of
2
+ // truth for the initialize handshake; never a literal at the callsite.
3
+ export const ACP_PROTOCOL_VERSION = 1;
4
+ export function isRequest(m) {
5
+ return "method" in m && "id" in m;
6
+ }
7
+ export function isNotification(m) {
8
+ return "method" in m && !("id" in m);
9
+ }
10
+ export function isResponse(m) {
11
+ return !("method" in m) && "id" in m;
12
+ }
13
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../src/acp/protocol.ts"],"names":[],"mappings":"AAAA,oEAAoE;AACpE,uEAAuE;AACvE,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC;AA6BtC,MAAM,UAAU,SAAS,CAAC,CAAiB;IACzC,OAAO,QAAQ,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC;AACpC,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,CAAiB;IAC9C,OAAO,QAAQ,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC;AACvC,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,CAAiB;IAC1C,OAAO,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC;AACvC,CAAC"}
@@ -0,0 +1,134 @@
1
+ import { hostname, userInfo } from "node:os";
2
+ import { hashBundle, keyFor, serialize, wrap, } from "./envelope.js";
3
+ import { logger } from "./util/log.js";
4
+ const log = logger("archive");
5
+ export class ArchiveLoop {
6
+ opts;
7
+ host;
8
+ pending = new Map();
9
+ // Tracks sessionId -> meta we know about (just cwd/title/agentId for
10
+ // now), so the rule fn has something to decide on.
11
+ meta = new Map();
12
+ stopped = false;
13
+ constructor(opts) {
14
+ this.opts = opts;
15
+ this.host = opts.host ?? defaultHost();
16
+ }
17
+ setMeta(sessionId, meta) {
18
+ this.meta.set(sessionId, meta);
19
+ }
20
+ forgetSession(sessionId) {
21
+ const p = this.pending.get(sessionId);
22
+ if (p) {
23
+ clearTimeout(p.timer);
24
+ this.pending.delete(sessionId);
25
+ }
26
+ this.meta.delete(sessionId);
27
+ }
28
+ // Cancel any pending debounced upload and synchronously run one final
29
+ // flush — used when a session transitions live → cold so we capture
30
+ // its last state. Errors are logged, not thrown, so callers can
31
+ // fire-and-forget. The session's meta is dropped after the flush
32
+ // resolves.
33
+ async finalFlush(sessionId) {
34
+ const existing = this.pending.get(sessionId);
35
+ if (existing) {
36
+ clearTimeout(existing.timer);
37
+ this.pending.delete(sessionId);
38
+ }
39
+ try {
40
+ await this.flush(sessionId);
41
+ }
42
+ catch (err) {
43
+ log.warn(`final flush ${sessionId} failed: ${err.message}`);
44
+ }
45
+ this.meta.delete(sessionId);
46
+ }
47
+ markDirty(sessionId) {
48
+ if (this.stopped) {
49
+ return;
50
+ }
51
+ const existing = this.pending.get(sessionId);
52
+ if (existing) {
53
+ clearTimeout(existing.timer);
54
+ }
55
+ const timer = setTimeout(() => {
56
+ this.pending.delete(sessionId);
57
+ void this.flush(sessionId).catch((err) => {
58
+ log.warn(`flush ${sessionId} failed: ${err.message}`);
59
+ });
60
+ }, this.opts.debounceMs);
61
+ timer.unref();
62
+ this.pending.set(sessionId, { timer });
63
+ }
64
+ stop() {
65
+ this.stopped = true;
66
+ for (const p of this.pending.values()) {
67
+ clearTimeout(p.timer);
68
+ }
69
+ this.pending.clear();
70
+ }
71
+ // flushNow is exposed for tests so they don't have to wait out the
72
+ // debounce window. Production code goes through markDirty.
73
+ async flushNow(sessionId) {
74
+ const existing = this.pending.get(sessionId);
75
+ if (existing) {
76
+ clearTimeout(existing.timer);
77
+ this.pending.delete(sessionId);
78
+ }
79
+ await this.flush(sessionId);
80
+ }
81
+ async flush(sessionId) {
82
+ const bundle = await this.opts.daemon.exportSession(sessionId);
83
+ const lineageId = bundle.session.lineageId;
84
+ if (!lineageId) {
85
+ log.warn(`session ${sessionId} export had no lineageId; skipping`);
86
+ return;
87
+ }
88
+ const meta = this.meta.get(sessionId);
89
+ const rule = this.opts.getRule();
90
+ const shouldArchive = await runRule(rule, {
91
+ sessionId,
92
+ lineageId,
93
+ meta: meta ?? {},
94
+ });
95
+ if (!shouldArchive) {
96
+ log.debug(`rule skipped session ${sessionId} (lineage ${lineageId})`);
97
+ return;
98
+ }
99
+ const newHash = hashBundle(bundle);
100
+ const prev = this.opts.state.get(lineageId);
101
+ if (prev.lastUploadedHash === newHash) {
102
+ log.debug(`unchanged content for lineage ${lineageId} — skip upload`);
103
+ return;
104
+ }
105
+ const envelope = wrap(bundle, lineageId, this.host);
106
+ await this.opts.backend.put(keyFor(lineageId), serialize(envelope));
107
+ await this.opts.state.set(lineageId, {
108
+ lastUploadedHash: newHash,
109
+ lastUploadedAt: envelope.uploadedAt,
110
+ });
111
+ log.info(`uploaded lineage=${lineageId} session=${sessionId} hash=${newHash.slice(0, 22)}…`);
112
+ }
113
+ }
114
+ async function runRule(rule, ev) {
115
+ try {
116
+ const r = await rule(ev);
117
+ return r !== false;
118
+ }
119
+ catch (err) {
120
+ log.warn(`rule threw for ${ev.sessionId}: ${err.message}; defaulting to archive`);
121
+ return true;
122
+ }
123
+ }
124
+ function defaultHost() {
125
+ let user = "unknown";
126
+ try {
127
+ user = userInfo().username;
128
+ }
129
+ catch {
130
+ // happens in some sandboxes; fall through
131
+ }
132
+ return { host: hostname(), user };
133
+ }
134
+ //# sourceMappingURL=archive-loop.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"archive-loop.js","sourceRoot":"","sources":["../src/archive-loop.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import { FsBackend } from "./fs.js";
2
+ import { GoogleDriveBackend } from "./google-drive.js";
3
+ export function makeBackend(config) {
4
+ switch (config.backend) {
5
+ case "fs":
6
+ return new FsBackend({ dir: config.fsDir });
7
+ case "google-drive":
8
+ return new GoogleDriveBackend({
9
+ credentialsPath: config.credentialsPath,
10
+ tokenPath: config.tokenPath,
11
+ folderName: config.driveFolderName,
12
+ });
13
+ default: {
14
+ const exhaustive = config.backend;
15
+ throw new Error(`unknown backend: ${String(exhaustive)}`);
16
+ }
17
+ }
18
+ }
19
+ //# sourceMappingURL=factory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"factory.js","sourceRoot":"","sources":["../../src/backend/factory.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAGvD,MAAM,UAAU,WAAW,CAAC,MAAc;IACxC,QAAQ,MAAM,CAAC,OAAO,EAAE,CAAC;QACvB,KAAK,IAAI;YACP,OAAO,IAAI,SAAS,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;QAC9C,KAAK,cAAc;YACjB,OAAO,IAAI,kBAAkB,CAAC;gBAC5B,eAAe,EAAE,MAAM,CAAC,eAAe;gBACvC,SAAS,EAAE,MAAM,CAAC,SAAS;gBAC3B,UAAU,EAAE,MAAM,CAAC,eAAe;aACnC,CAAC,CAAC;QACL,OAAO,CAAC,CAAC,CAAC;YACR,MAAM,UAAU,GAAU,MAAM,CAAC,OAAO,CAAC;YACzC,MAAM,IAAI,KAAK,CAAC,oBAAoB,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC;QAC5D,CAAC;IACH,CAAC;AACH,CAAC"}
@@ -0,0 +1,73 @@
1
+ import { mkdir, readFile, readdir, rename, stat, unlink, writeFile, } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { logger } from "../util/log.js";
4
+ const log = logger("backend.fs");
5
+ // Filesystem-backed implementation. Useful for tests, for users who want
6
+ // to point a folder-sync tool (Syncthing, Dropbox) at the archive
7
+ // directory, and for development against a local daemon.
8
+ export class FsBackend {
9
+ opts;
10
+ constructor(opts) {
11
+ this.opts = opts;
12
+ }
13
+ async init() {
14
+ await mkdir(this.opts.dir, { recursive: true });
15
+ log.info(`fs backend ready at ${this.opts.dir}`);
16
+ }
17
+ async list() {
18
+ let names;
19
+ try {
20
+ names = await readdir(this.opts.dir);
21
+ }
22
+ catch (err) {
23
+ const e = err;
24
+ if (e.code === "ENOENT") {
25
+ return [];
26
+ }
27
+ throw err;
28
+ }
29
+ const out = [];
30
+ for (const name of names) {
31
+ if (name.startsWith(".") || name.endsWith(".tmp")) {
32
+ continue;
33
+ }
34
+ const full = resolve(this.opts.dir, name);
35
+ try {
36
+ const s = await stat(full);
37
+ if (!s.isFile()) {
38
+ continue;
39
+ }
40
+ out.push({
41
+ key: name,
42
+ size: s.size,
43
+ modifiedAt: s.mtime.toISOString(),
44
+ });
45
+ }
46
+ catch (err) {
47
+ log.debug(`stat ${full}: ${err.message}`);
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ async get(key) {
53
+ return readFile(resolve(this.opts.dir, key));
54
+ }
55
+ async put(key, data) {
56
+ const dest = resolve(this.opts.dir, key);
57
+ const tmp = `${dest}.tmp`;
58
+ await writeFile(tmp, data);
59
+ await rename(tmp, dest);
60
+ }
61
+ async delete(key) {
62
+ try {
63
+ await unlink(resolve(this.opts.dir, key));
64
+ }
65
+ catch (err) {
66
+ const e = err;
67
+ if (e.code !== "ENOENT") {
68
+ throw err;
69
+ }
70
+ }
71
+ }
72
+ }
73
+ //# sourceMappingURL=fs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs.js","sourceRoot":"","sources":["../../src/backend/fs.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,EACL,QAAQ,EACR,OAAO,EACP,MAAM,EACN,IAAI,EACJ,MAAM,EACN,SAAS,GACV,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAGxC,MAAM,GAAG,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC;AAMjC,yEAAyE;AACzE,kEAAkE;AAClE,yDAAyD;AACzD,MAAM,OAAO,SAAS;IACS;IAA7B,YAA6B,IAAsB;QAAtB,SAAI,GAAJ,IAAI,CAAkB;IAAG,CAAC;IAEvD,KAAK,CAAC,IAAI;QACR,MAAM,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAChD,GAAG,CAAC,IAAI,CAAC,uBAAuB,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IACnD,CAAC;IAED,KAAK,CAAC,IAAI;QACR,IAAI,KAAe,CAAC;QACpB,IAAI,CAAC;YACH,KAAK,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACvC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,CAAC,GAAG,GAA4B,CAAC;YACvC,IAAI,CAAC,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACxB,OAAO,EAAE,CAAC;YACZ,CAAC;YACD,MAAM,GAAG,CAAC;QACZ,CAAC;QACD,MAAM,GAAG,GAAuB,EAAE,CAAC;QACnC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;gBAClD,SAAS;YACX,CAAC;YACD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YAC1C,IAAI,CAAC;gBACH,MAAM,CAAC,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC;gBAC3B,IAAI,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC;oBAChB,SAAS;gBACX,CAAC;gBACD,GAAG,CAAC,IAAI,CAAC;oBACP,GAAG,EAAE,IAAI;oBACT,IAAI,EAAE,CAAC,CAAC,IAAI;oBACZ,UAAU,EAAE,CAAC,CAAC,KAAK,CAAC,WAAW,EAAE;iBAClC,CAAC,CAAC;YACL,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,GAAG,CAAC,KAAK,CAAC,QAAQ,IAAI,KAAM,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;YACvD,CAAC;QACH,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,OAAO,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;IAC/C,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,IAAY;QACjC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACzC,MAAM,GAAG,GAAG,GAAG,IAAI,MAAM,CAAC;QAC1B,MAAM,SAAS,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QAC3B,MAAM,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAC1B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;QAC5C,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,CAAC,GAAG,GAA4B,CAAC;YACvC,IAAI,CAAC,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACxB,MAAM,GAAG,CAAC;YACZ,CAAC;QACH,CAAC;IACH,CAAC;CACF"}