@augmentd-labs/canvas-edge 0.6.2 → 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.
Files changed (34) hide show
  1. package/README.md +5 -0
  2. package/node_modules/canvas-stored/README.md +189 -9
  3. package/node_modules/canvas-stored/package.json +2 -2
  4. package/node_modules/canvas-stored/src/backends/BackendManager.js +11 -1
  5. package/node_modules/canvas-stored/src/backends/StorageBackend.js +24 -12
  6. package/node_modules/canvas-stored/src/backends/cacache/index.js +49 -6
  7. package/node_modules/canvas-stored/src/backends/canvas/index.js +41 -4
  8. package/node_modules/canvas-stored/src/backends/file/index.js +181 -39
  9. package/node_modules/canvas-stored/src/backends/gdrive/index.js +57 -10
  10. package/node_modules/canvas-stored/src/backends/http/index.js +3 -1
  11. package/node_modules/canvas-stored/src/backends/s3/index.js +244 -15
  12. package/node_modules/canvas-stored/src/cache/collect-garbage.js +47 -0
  13. package/node_modules/canvas-stored/src/cache/compact-index.js +13 -0
  14. package/node_modules/canvas-stored/src/cache/index.js +39 -5
  15. package/node_modules/canvas-stored/src/cache/ingest-stream.js +65 -0
  16. package/node_modules/canvas-stored/src/index/index.js +5 -3
  17. package/node_modules/canvas-stored/src/index.js +733 -132
  18. package/node_modules/canvas-stored/src/runtime/MutationQueue.js +60 -0
  19. package/node_modules/canvas-stored/src/storage/lmdb.js +62 -0
  20. package/node_modules/canvas-stored/src/storage/recover-index.js +33 -0
  21. package/node_modules/canvas-stored/src/storage/verify-worker.js +23 -0
  22. package/node_modules/canvas-stored/src/sync/Ledger.js +21 -3
  23. package/node_modules/canvas-stored/src/sync/Mirror.js +334 -25
  24. package/node_modules/canvas-stored/src/sync/REPLICAS.md +64 -0
  25. package/node_modules/canvas-stored/src/sync/ReplicaService.js +623 -0
  26. package/node_modules/canvas-stored/src/sync/SyncQueue.js +23 -103
  27. package/node_modules/canvas-stored/src/trash.js +219 -0
  28. package/node_modules/canvas-stored/src/utils/common.js +7 -0
  29. package/package.json +5 -4
  30. package/src/binding.js +33 -0
  31. package/src/control.js +13 -0
  32. package/src/daemon.js +14 -1
  33. package/src/env.js +1 -1
  34. package/src/mirror-runtime.js +15 -4
@@ -1,111 +1,31 @@
1
- import { Worker } from 'worker_threads';
2
- import { EventEmitter } from 'events';
3
- import cacache from 'cacache';
4
- import Debug from 'debug';
1
+ import { EventEmitter } from 'node:events';
2
+ import { randomUUID, createHash } from 'node:crypto';
5
3
 
6
- const debug = Debug('stored:sync');
7
-
8
- /**
9
- * Background sync queue for remote backend writes.
10
- *
11
- * Every job is `{ id, cacheRoot, cacheKey, targets[] }` — the bytes already
12
- * sit in the cache under `cacheKey`; each target gets a copy. Two lanes:
13
- * - `driver: 'file'` targets are streamed by a lazily-spawned worker thread
14
- * (disk-to-disk copy is the one thing worth taking off the event loop);
15
- * - every other driver is committed IN-PROCESS through its live backend
16
- * (`backend.commit(key, cachePath)`): network uploads are async I/O, and
17
- * the driver's credentials, token and path cache live on the main thread.
18
- * One `synced` event per job carries the merged results of both lanes.
19
- */
4
+ /** Compatibility adapter: ordinary remote transfers use the same durable
5
+ * executor and verification contract as explicit replica plans. */
20
6
  export default class SyncQueue extends EventEmitter {
21
- #worker = null;
22
- #seq = 0;
23
- #pending = new Map(); // seq → resolve(results) for in-flight worker jobs
24
- #resolveBackend;
25
-
26
- /** @param {{ resolveBackend?: (name:string) => object|undefined }} [options] */
27
- constructor({ resolveBackend = null } = {}) {
7
+ #service; #listener;
8
+ constructor({ service }) {
28
9
  super();
29
- this.#resolveBackend = typeof resolveBackend === 'function' ? resolveBackend : () => undefined;
10
+ this.#service = service;
11
+ this.#listener = operation => {
12
+ if (!operation.plan.transportJob) return;
13
+ if (!['complete', 'blocked'].includes(operation.state)) return;
14
+ this.emit('synced', { id: operation.plan.contentId, operationId: operation.id,
15
+ results: operation.plan.replicas.map((target, i) => ({
16
+ backend: target.backend, key: operation.outcomes[i]?.key || target.key,
17
+ success: operation.state === 'complete',
18
+ ...(operation.error ? { error: operation.error.message } : {}),
19
+ })) });
20
+ };
21
+ service.on('changed', this.#listener);
30
22
  }
31
-
32
23
  enqueue(job) {
33
- const targets = Array.isArray(job?.targets) ? job.targets : [];
34
- const viaWorker = targets.filter((t) => t.driver === 'file');
35
- const inProcess = targets.filter((t) => t.driver !== 'file');
36
- const lanes = [];
37
- if (viaWorker.length) lanes.push(this.#viaWorker({ ...job, targets: viaWorker }));
38
- if (inProcess.length) lanes.push(this.#inProcess(job, inProcess));
39
- if (!lanes.length) return;
40
-
41
- Promise.all(lanes)
42
- .then((parts) => {
43
- const results = parts.flat();
44
- debug(`Sync complete: ${String(job.id).slice(0, 19)}... → ${results.map((r) => `${r.backend}:${r.success}`).join(', ')}`);
45
- this.emit('synced', { id: job.id, results });
46
- })
47
- .catch((err) => this.emit('error', err));
48
- }
49
-
50
- async stop() {
51
- if (this.#worker) {
52
- await this.#worker.terminate();
53
- this.#worker = null;
54
- }
55
- // Jobs the worker never answered must not hang their `synced` forever.
56
- for (const [seq, resolve] of this.#pending) {
57
- this.#pending.delete(seq);
58
- resolve([]);
59
- }
60
- }
61
-
62
- #viaWorker(job) {
63
- if (!this.#worker) this.#spawnWorker();
64
- const seq = ++this.#seq;
65
- return new Promise((resolve) => {
66
- this.#pending.set(seq, resolve);
67
- this.#worker.postMessage({ seq, ...job });
68
- });
69
- }
70
-
71
- async #inProcess({ cacheRoot, cacheKey }, targets) {
72
- const info = await cacache.get.info(cacheRoot, cacheKey).catch(() => null);
73
- if (!info) {
74
- return targets.map((t) => ({ backend: t.name, key: t.key, success: false, error: 'Cache read failed: entry not found' }));
75
- }
76
- const results = [];
77
- for (const target of targets) {
78
- const backend = this.#resolveBackend(target.name);
79
- if (!backend || typeof backend.commit !== 'function') {
80
- results.push({ backend: target.name, key: target.key, success: false, error: `Backend not registered: ${target.name}` });
81
- continue;
82
- }
83
- try {
84
- await backend.commit(target.key, info.path);
85
- results.push({ backend: target.name, key: target.key, success: true });
86
- } catch (err) {
87
- debug(`In-process sync to ${target.name}:${target.key} failed: ${err.message}`);
88
- results.push({ backend: target.name, key: target.key, success: false, error: err.message });
89
- }
90
- }
91
- return results;
92
- }
93
-
94
- #spawnWorker() {
95
- this.#worker = new Worker(new URL('./worker.js', import.meta.url));
96
- this.#worker.on('message', (msg) => {
97
- const resolve = this.#pending.get(msg.seq);
98
- if (resolve) {
99
- this.#pending.delete(msg.seq);
100
- resolve(msg.results || []);
101
- return;
102
- }
103
- // No correlation (foreign/legacy message) — surface as-is.
104
- this.emit('synced', msg);
105
- });
106
- this.#worker.on('error', (err) => {
107
- debug(`Worker error: ${err.message}`);
108
- this.emit('error', err);
24
+ return this.#service.submit({
25
+ id: `transfer:${randomUUID()}`, owner: `transfer:${createHash('sha256').update(JSON.stringify([job.id, job.targets])).digest('hex')}`,
26
+ contentId: job.id, sourceCacheKey: job.cacheKey, transportJob: true,
27
+ replicas: job.targets.map(t => ({ backend: t.name, key: t.key, mirror: false, onConflict: 'error' })),
109
28
  });
110
29
  }
30
+ async stop() { this.#service.off('changed', this.#listener); }
111
31
  }
@@ -0,0 +1,219 @@
1
+ import crypto from 'node:crypto';
2
+ import { Readable, Transform } from 'node:stream';
3
+ import { pipeline } from 'node:stream/promises';
4
+
5
+ // Per-deletion provenance, separate from the deduplicated retained blob pool.
6
+ // Restoring one path never consumes the bytes needed by another trash item.
7
+ export default class BackendTrash {
8
+ tasks = new Map();
9
+ constructor(stored) { this.stored = stored; }
10
+ get db() { return this._db ??= this.stored.index.openDB('backend-trash-v1'); }
11
+ get days() { return this.stored.retention?.days || 0; }
12
+
13
+ prepare(backend, key, { retained = null, directory = false, mtime = null, reason = 'delete' } = {}) {
14
+ if (!this.days || (!directory && !retained)) return null;
15
+ const item = {
16
+ id: crypto.randomUUID(), backend, key, type: directory ? 'directory' : 'file',
17
+ sha256: retained?.sha256 ?? null, size: retained?.size ?? null,
18
+ mimeType: retained?.mimeType ?? null, mtime, reason, deletedAt: Date.now(), state: 'pending',
19
+ };
20
+ this.db.putSync(`item:${item.id}`, item);
21
+ return item;
22
+ }
23
+
24
+ finish(item) {
25
+ if (item) this.db.putSync(`item:${item.id}`, { ...item, state: 'active' });
26
+ }
27
+
28
+ async remove(backendName, key, { operationId, sha256 } = {}) {
29
+ if (!operationId || !/^[a-f0-9]{64}$/i.test(sha256 || '')) return { ok: false, reason: 'content-identity-required' };
30
+ const backend = this.stored.getBackend(backendName);
31
+ if (!backend?.canDelete || backend.config.readOnly || backend.config.removalPolicy?.acceptPropagatedDeletions !== true) return { ok: false, reason: 'protected-backend' };
32
+ // Remote drivers must implement conditional removal before joining this
33
+ // executor. A stale listing is never authority to erase a newer object.
34
+ if (!['file', 'cacache'].includes(backend.config.driver)) return { ok: false, reason: 'conditional-removal-unsupported' };
35
+ if (backend.config.driver === 'file' && !this.days) return { ok: false, reason: 'file-recovery-unavailable' };
36
+ const digest = crypto.createHash('sha256').update(operationId).digest('hex');
37
+ const id = `${digest.slice(0, 8)}-${digest.slice(8, 12)}-${digest.slice(12, 16)}-${digest.slice(16, 20)}-${digest.slice(20, 32)}`;
38
+ let item = this.db.get(`item:${id}`);
39
+ if (item && (item.backend !== backendName || item.key !== key || item.sha256 !== sha256)) return { ok: false, reason: 'operation-conflict' };
40
+ if (item && ['active', 'restored', 'discarded'].includes(item.state)) return { ok: true, trashId: id, state: item.state };
41
+ if (typeof backend.verifyRoot === 'function' && !(await backend.verifyRoot()).ok) return { ok: false, reason: 'backend-unavailable' };
42
+ const current = await backend.stat(key);
43
+ if (!current) {
44
+ if (item?.recoveryReady) { this.finish(item); return { ok: true, trashId: id }; }
45
+ return { ok: true, missing: true };
46
+ }
47
+ item ||= { id, backend: backendName, key, type: 'file', sha256, reason: 'propagated-delete', manual: true,
48
+ deletedAt: Date.now(), state: 'pending', cacheKey: `trash:${id}`, size: current.size, mtime: current.modified };
49
+ this.db.putSync(`item:${id}`, item);
50
+ if (!item.recoveryReady) {
51
+ const input = await backend.get(key, { stream: true });
52
+ if (!input) return { ok: false, reason: 'source-unreadable' };
53
+ const hash = crypto.createHash('sha256');
54
+ await pipeline(Buffer.isBuffer(input) ? Readable.from([input]) : input,
55
+ new Transform({ transform(chunk, encoding, done) { hash.update(chunk); done(null, chunk); } }),
56
+ this.stored.cache.putStream(item.cacheKey, { trashId: id }));
57
+ if (hash.digest('hex') !== sha256) { await this.stored.cache.delete(item.cacheKey); return { ok: false, reason: 'source-changed' }; }
58
+ item.recoveryReady = true; this.db.putSync(`item:${id}`, item);
59
+ }
60
+ const result = await this.stored.removeObject(backendName, key, { ifMatch: sha256, origin: `trash:${id}`, trashItem: item });
61
+ if (!result.ok) return result;
62
+ this.finish(item);
63
+ return { ...result, trashId: id };
64
+ }
65
+ quarantine(item) {
66
+ item.quarantined = true;
67
+ this.db.putSync(`item:${item.id}`, item);
68
+ }
69
+
70
+ async importLegacy(backendName) {
71
+ const backend = this.stored.getBackend(backendName);
72
+ if (!backend || !this.days) return;
73
+ for (const retained of this.stored.listRetained({ backend: backendName, limit: Infinity })) {
74
+ const keys = retained.legacyKeys ?? (retained.trashVersion ? [] : retained.keys || []);
75
+ for (const address of keys) {
76
+ if (!address.startsWith(`${backendName}:`)) continue;
77
+ const key = address.slice(backendName.length + 1);
78
+ const deletedAt = retained.legacyAt ?? retained.lastAt;
79
+ const id = crypto.createHash('sha256').update(JSON.stringify([backendName, key, retained.sha256, deletedAt])).digest('hex');
80
+ if (this.db.get(`legacy:${id}`)) continue;
81
+ // Old retention mixed overwrites and deletes. Import absent
82
+ // original paths only, never old versions of a live file.
83
+ const current = await backend.stat(key);
84
+ if (!current) this.db.putSync(`item:${id}`, {
85
+ id, backend: backendName, key, type: 'file', sha256: retained.sha256,
86
+ size: retained.size, mimeType: retained.mimeType, mtime: null,
87
+ deletedAt, state: 'active', legacy: true,
88
+ });
89
+ this.db.putSync(`legacy:${id}`, true);
90
+ }
91
+ }
92
+ }
93
+
94
+ async get(backendName, id) {
95
+ let item = this.db.get(`item:${id}`);
96
+ if (!item || item.backend !== backendName || ['restored', 'discarded'].includes(item.state)
97
+ || (!item.manual && (!this.days || Date.now() - item.deletedAt >= this.days * 86400000))) return null;
98
+ if (item.state === 'pending') {
99
+ if (item.manual && !item.recoveryReady) return null;
100
+ const backend = this.stored.getBackend(backendName);
101
+ const deleted = item.type === 'directory' || item.quarantined
102
+ ? await backend.hasTrashedDirectory(item.id)
103
+ : !await backend.stat(item.key);
104
+ if (!deleted) return null;
105
+ this.finish(item);
106
+ item = { ...item, state: 'active' };
107
+ }
108
+ return item;
109
+ }
110
+
111
+ async list(backendName, { cursor = null, limit = 200 } = {}) {
112
+ limit = Math.max(1, Math.min(1000, Number(limit) || 200));
113
+ await this.importLegacy(backendName);
114
+ const items = [];
115
+ for (const { key, value } of [...this.db.getRange()]) {
116
+ if (!key.startsWith('item:') || value.backend !== backendName
117
+ || (cursor && value.id.localeCompare(cursor) <= 0)) continue;
118
+ const item = await this.get(backendName, value.id);
119
+ // Overwrite captures back retained versions, not user deletions.
120
+ if (item && item.reason !== 'overwrite') items.push(item);
121
+ }
122
+ items.sort((a, b) => a.id.localeCompare(b.id));
123
+ const page = items.slice(0, limit);
124
+ return { items: page.map(({ state: _state, backend: _backend, ...item }) => item),
125
+ cursor: items.length > limit ? page.at(-1).id : null, retention: this.stored.retention };
126
+ }
127
+
128
+ withItemLock(id, fn) {
129
+ const task = (this.tasks.get(id) || Promise.resolve()).catch(() => {}).then(fn);
130
+ this.tasks.set(id, task);
131
+ void task.finally(() => { if (this.tasks.get(id) === task) this.tasks.delete(id); }).catch(() => {});
132
+ return task;
133
+ }
134
+
135
+ restore(backendName, id, options = {}) {
136
+ return this.withItemLock(id, () => this.restoreItem(backendName, id, options));
137
+ }
138
+
139
+ async restoreItem(backendName, id, { origin = null } = {}) {
140
+ const item = await this.get(backendName, id);
141
+ if (!item) return { ok: false, reason: 'not-found', id };
142
+ if (item.state === 'discarding') return { ok: false, reason: 'deletion-pending', id };
143
+ await this.stored.getBackend(backendName).validateTrashTarget?.(item.key);
144
+ const result = item.cacheKey
145
+ ? await this.restorePinned(item, origin)
146
+ : item.type === 'directory' || item.quarantined
147
+ ? await this.stored.getBackend(backendName).restoreTrashedDirectory(id, item.key)
148
+ : await this.stored.restoreRetained(item.sha256, {
149
+ backend: backendName, key: item.key, ifNoneMatch: '*', mtime: item.mtime, origin,
150
+ });
151
+ if (item.quarantined && result.reason === 'target-exists') result.reason = 'precondition-failed';
152
+ if (result?.ok && item.quarantined) await this.stored.refreshObject(backendName, item.key, origin);
153
+ if (result?.ok) this.db.putSync(`item:${id}`, { ...item, state: 'restored', restoredAt: Date.now() });
154
+ if (result?.ok && item.cacheKey) {
155
+ await this.stored.cache.delete(item.cacheKey);
156
+ if (item.quarantined) await this.stored.getBackend(backendName).dropTrashedDirectory(id);
157
+ }
158
+ return { ...result, id, key: item.key, type: item.type };
159
+ }
160
+
161
+ async restorePinned(item, origin) {
162
+ const backend = this.stored.getBackend(item.backend);
163
+ if (!backend?.canWrite || backend.config.readOnly) return { ok: false, reason: 'read-only-backend' };
164
+ const hash = crypto.createHash('sha256');
165
+ for await (const chunk of this.stored.cache.getStream(item.cacheKey)) hash.update(chunk);
166
+ if (hash.digest('hex') !== item.sha256) return { ok: false, reason: 'recovery-content-changed' };
167
+ if (backend.config.driver === 'cacache') {
168
+ // Stored's physical mutation lock covers this check and put. The
169
+ // managed blob store has no external writers.
170
+ if (await backend.stat(item.key)) return { ok: false, reason: 'precondition-failed' };
171
+ const info = await this.stored.cache.getInfo(item.cacheKey);
172
+ return this.stored.put(info.path, { backends: [item.backend], key: item.key, origin });
173
+ }
174
+ return this.stored.writeObject(item.backend, item.key, this.stored.cache.getStream(item.cacheKey), { sha256: item.sha256, ifNoneMatch: '*', origin, mtime: item.mtime });
175
+ }
176
+
177
+ hasReference(sha256, exceptId, address = null) {
178
+ for (const { key, value } of this.db.getRange()) {
179
+ if (key.startsWith('item:') && value.id !== exceptId && value.sha256 === sha256
180
+ && !['discarded', 'restored'].includes(value.state)
181
+ && (!address || `${value.backend}:${value.key}` === address)) return true;
182
+ }
183
+ return false;
184
+ }
185
+
186
+ discard(backendName, id) {
187
+ return this.withItemLock(id, () => this.discardItem(backendName, id));
188
+ }
189
+
190
+ async discardItem(backendName, id) {
191
+ const item = this.db.get(`item:${id}`);
192
+ if (!item || item.backend !== backendName || item.state === 'restored') return { id, ok: false, reason: 'not-found' };
193
+ const result = { id, key: item.key, type: item.type, ok: true };
194
+ if (item.state === 'discarded') return { ...result, alreadyDeleted: true };
195
+ const backend = this.stored.getBackend(backendName);
196
+ if (!backend?.canDelete) return { id, ok: false, reason: 'read-only-backend' };
197
+ // Persist intent before removing bytes. Interrupted/failed deletion can
198
+ // be retried, but must not race a subsequent attempt to restore it.
199
+ this.db.putSync(`item:${id}`, { ...item, state: 'discarding' });
200
+ const finish = () => this.db.putSync(`item:${id}`, { ...item, state: 'discarded', discardedAt: Date.now() });
201
+ if (item.cacheKey) { if (item.quarantined) await backend.dropTrashedDirectory(id); await this.stored.cache.delete(item.cacheKey); finish(); }
202
+ else if (item.type === 'directory') { await backend.dropTrashedDirectory(id); finish(); }
203
+ else if (item.quarantined) { await backend.dropTrashedDirectory(id); await this.stored.discardTrashBytes(item, finish); }
204
+ else await this.stored.discardTrashBytes(item, finish);
205
+ return result;
206
+ }
207
+
208
+ async sweep(now) {
209
+ for (const { key, value } of [...this.db.getRange()]) {
210
+ if (!key.startsWith('item:') || value.manual || now - value.deletedAt < this.days * 86400000) continue;
211
+ if (value.type === 'directory' || value.quarantined) {
212
+ const backend = this.stored.getBackend(value.backend);
213
+ if (!backend) continue;
214
+ await backend.dropTrashedDirectory(value.id);
215
+ }
216
+ this.db.removeSync(key);
217
+ }
218
+ }
219
+ }
@@ -28,6 +28,13 @@ export function isFile(input) {
28
28
  catch { return false; }
29
29
  }
30
30
 
31
+ /** Ingestion must not synchronously stat paths that may live on a NAS. */
32
+ export async function isFileAsync(input) {
33
+ if (typeof input !== 'string') return false;
34
+ try { return (await fs.promises.stat(input)).isFile(); }
35
+ catch { return false; }
36
+ }
37
+
31
38
  export function isBuffer(input) {
32
39
  return Buffer.isBuffer(input);
33
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augmentd-labs/canvas-edge",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Canvas device runtime: folder mirrors (canvas-stored Mirror engine) + edge tunnel client (dist artifact)",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -21,7 +21,8 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@augmentd-labs/canvas-protocol": "^0.5.0",
24
- "canvas-stored": "1.9.3",
24
+ "canvas-stored": "1.18.0",
25
+ "@aws-sdk/client-s3": "^3.1149.0",
25
26
  "cacache": "^20.0.4",
26
27
  "chokidar": "^5.0.0",
27
28
  "debug": "^4.4.3",
@@ -31,7 +32,7 @@
31
32
  "fs-extra": "^11.3.4",
32
33
  "image-size": "^2.0.2",
33
34
  "imap": "^0.8.19",
34
- "lmdb": "^3.5.2",
35
+ "lmdb": "3.5.7",
35
36
  "music-metadata": "^11.9.0",
36
37
  "picomatch": "^2.3.1",
37
38
  "fastify": "^4.29.1",
@@ -47,6 +48,6 @@
47
48
  "bundleDependencies": [
48
49
  "canvas-stored"
49
50
  ],
50
- "canvasRev": "52fc07d",
51
+ "canvasRev": "9cc56fa",
51
52
  "canvasSource": "runtimes/edge"
52
53
  }
package/src/binding.js ADDED
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ import crypto from 'crypto';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+
7
+ /** Where the binding id lives inside a mirrored folder. */
8
+ export const BINDING_FILE = '.workspace/binding';
9
+
10
+ /**
11
+ * The id shared by a mirrored folder and its sync history (`stateDir`),
12
+ * created on first start. History is refused for a folder that does not carry
13
+ * the id, and for an empty folder when history exists but predates bindings:
14
+ * both are what an unmounted drive looks like.
15
+ */
16
+ export function bindFolder(folder, stateDir, hadHistory) {
17
+ const read = (file) => { try { return fs.readFileSync(file, 'utf8').trim() || null; } catch { return null; } };
18
+ const marker = path.join(folder, BINDING_FILE);
19
+ const record = path.join(stateDir, 'binding');
20
+ const known = read(record);
21
+ if (known) {
22
+ if (read(marker) !== known) throw new Error(`${folder} is not the folder this mirror's sync history belongs to (drive not mounted, or the folder was replaced); nothing was synced. Mount it, or remove ${stateDir} to start the mirror from scratch`);
23
+ return known;
24
+ }
25
+ const empty = !fs.existsSync(folder) || fs.readdirSync(folder).every((name) => name.startsWith('.'));
26
+ if (hadHistory && empty) throw new Error(`${folder} is empty but ${stateDir} holds sync history for it (drive not mounted?); nothing was synced. Mount it, or remove ${stateDir} to start the mirror from scratch`);
27
+ const id = crypto.randomBytes(16).toString('hex');
28
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
29
+ fs.mkdirSync(stateDir, { recursive: true });
30
+ fs.writeFileSync(marker, `${id}\n`);
31
+ fs.writeFileSync(record, `${id}\n`);
32
+ return id;
33
+ }
package/src/control.js CHANGED
@@ -31,6 +31,19 @@ export async function startControl({ runtimes, reload, shutdown, logger }) {
31
31
  r.nudge();
32
32
  return { ok: true };
33
33
  });
34
+ // Local deletions held by the mirror's delete guard: list, send, or undo.
35
+ app.get('/mirrors/:id/deletes', async (req, reply) => {
36
+ const r = byId(req.params.id);
37
+ if (typeof r?.heldDeletes !== 'function') return reply.code(404).send({ error: 'no such mirror' });
38
+ return { held: r.heldDeletes() };
39
+ });
40
+ app.post('/mirrors/:id/deletes/:verb', async (req, reply) => {
41
+ const r = byId(req.params.id);
42
+ if (typeof r?.heldDeletes !== 'function') return reply.code(404).send({ error: 'no such mirror' });
43
+ if (req.params.verb === 'approve') return { approved: r.approveDeletes() };
44
+ if (req.params.verb === 'restore') return { restored: r.restoreDeletes() };
45
+ return reply.code(404).send({ error: 'approve or restore' });
46
+ });
34
47
  app.post('/reload', async () => { await reload(); return { ok: true, mirrors: runtimes.size }; });
35
48
  app.post('/shutdown', async () => { setTimeout(() => shutdown(), 50); return { ok: true }; });
36
49
 
package/src/daemon.js CHANGED
@@ -8,6 +8,8 @@ import { MirrorRuntime } from './mirror-runtime.js';
8
8
  import { FuseRuntime } from './fuse-runtime.js';
9
9
  import { startControl } from './control.js';
10
10
 
11
+ const START_RETRY_MS = 60_000;
12
+
11
13
  /*
12
14
  * canvas-edge: the device-side daemon for real-folder mirrors (the non-FUSE
13
15
  * client of the sync protocol, for macOS/Windows and anyone who wants a plain
@@ -32,7 +34,13 @@ export async function main(argv = process.argv.slice(2)) {
32
34
  logger.error({ err: err?.message }, 'env-driven mirror config failed');
33
35
  }
34
36
 
37
+ // A mirror that cannot start (its drive is not mounted yet) is tried again.
38
+ let retry = null;
39
+ let stopping = false;
35
40
  const load = async () => {
41
+ clearTimeout(retry);
42
+ retry = null;
43
+ let failed = false;
36
44
  const wanted = daemonMirrors();
37
45
  const fuse = fuseMirrors();
38
46
  const wantedIds = new Set([...wanted, ...fuse].map((m) => m.id));
@@ -53,6 +61,8 @@ export async function main(argv = process.argv.slice(2)) {
53
61
  await rt.start();
54
62
  runtimes.set(mirror.id, rt);
55
63
  } catch (err) {
64
+ failed = true;
65
+ await rt.stop().catch(() => {});
56
66
  logger.error({ mirror: mirror.id, err: err?.message }, 'mirror failed to start');
57
67
  }
58
68
  }
@@ -67,9 +77,12 @@ export async function main(argv = process.argv.slice(2)) {
67
77
  logger.error({ unit: mirror.id, err: err?.message }, 'fuse unit failed to start');
68
78
  }
69
79
  }
80
+ if (failed && !stopping) {
81
+ retry = setTimeout(() => load().catch((err) => logger.error({ err: err?.message }, 'reload failed')), START_RETRY_MS);
82
+ retry.unref?.();
83
+ }
70
84
  };
71
85
 
72
- let stopping = false;
73
86
  const shutdown = async () => {
74
87
  if (stopping) return;
75
88
  stopping = true;
package/src/env.js CHANGED
@@ -99,7 +99,7 @@ export function ensureEnvConfig(env = process.env) {
99
99
  mountpoint: path.resolve(env.CANVAS_MIRROR_PATH ? String(env.CANVAS_MIRROR_PATH) : (existing.mountpoint || '/data')),
100
100
  pins: env.CANVAS_PINS != null ? list(env.CANVAS_PINS) : (existing.pins || []),
101
101
  ignore: env.CANVAS_IGNORE != null ? list(env.CANVAS_IGNORE) : (existing.ignore || []),
102
- conflicts: oneOf(env.CANVAS_CONFLICTS, ['prompt', 'rename'], existing.conflicts || 'prompt'),
102
+ conflicts: oneOf(env.CANVAS_CONFLICTS, ['prompt', 'rename'], existing.conflicts || 'rename'),
103
103
  deletes: oneOf(env.CANVAS_DELETES, ['propagate', 'keep'], existing.deletes || 'propagate'),
104
104
  direction: oneOf(env.CANVAS_DIRECTION, ['bi', 'pull', 'push'], existing.direction || 'bi'),
105
105
  client: 'daemon',
@@ -7,6 +7,7 @@ import { io } from 'socket.io-client';
7
7
  import Stored, { Mirror } from 'canvas-stored';
8
8
  import { MIRROR_IGNORE_DEFAULTS } from 'canvas-stored/src/sync/keys.js';
9
9
  import { DEFAULT_SYNC_EXCLUSIONS, WORKSPACE_INTERNAL_EXCLUSIONS } from '@augmentd-labs/canvas-protocol';
10
+ import { BINDING_FILE, bindFolder } from './binding.js';
10
11
 
11
12
  const STATUS_THROTTLE_MS = 5000;
12
13
  const STATUS_HEARTBEAT_MS = 60000;
@@ -19,7 +20,11 @@ const STATUS_HEARTBEAT_MS = 60000;
19
20
  * `<folder>/.workspace/` (mirror.json, db/stored, cache, tmp, trash,
20
21
  * conflicts) — the folder is self-describing and movable — unless the mirror
21
22
  * names a `stateDir` (or the daemon a CANVAS_EDGE_STATE_ROOT), in which case
22
- * the folder holds user files only and everything else lives there. Staging
23
+ * the folder holds user files plus `.workspace/binding` and everything else
24
+ * lives there. The binding is a random id written to both the folder and the
25
+ * state dir: sync history is only ever applied to the folder that carries it,
26
+ * so an unmounted drive or a replaced folder is refused instead of read as
27
+ * "every file was deleted". Staging
23
28
  * (temp) must share a filesystem with the folder for atomic placement, so
24
29
  * it stays under the state dir only when that is on the same device and
25
30
  * falls back to `<folder>/.stored-tmp` otherwise.
@@ -61,7 +66,9 @@ export class MirrorRuntime {
61
66
  const folder = this.folder;
62
67
  const internal = this.stateDir;
63
68
  const external = internal !== path.join(folder, '.workspace');
69
+ const hadHistory = fs.existsSync(path.join(internal, 'db', 'stored'));
64
70
  for (const d of ['db/stored', 'cache', 'trash', 'conflicts']) fs.mkdirSync(path.join(internal, d), { recursive: true });
71
+ const binding = bindFolder(folder, internal, hadHistory);
65
72
  let tempDir = path.join(internal, 'tmp');
66
73
  if (external && !canRenameInto(path.join(internal, 'tmp'), folder)) {
67
74
  // st_dev lies for bind mounts (one disk, two mounts, rename refused):
@@ -84,7 +91,8 @@ export class MirrorRuntime {
84
91
  this.#stored = new Stored({ root: path.join(internal, 'db', 'stored'), cache: { path: path.join(internal, 'cache') }, checksums: ['sha256'] });
85
92
  this.#stored.on('error', (err) => this.#logger.warn({ mirror: this.id, err: err?.message }, 'stored error'));
86
93
  this.#stored.addBackend('local', {
87
- driver: 'file', root: folder, watch: true, tempDir, followSymlinks: false, stabilityThreshold: 2000,
94
+ driver: 'file', root: folder, createRoot: false, marker: { file: BINDING_FILE, id: binding },
95
+ watch: true, tempDir, followSymlinks: false, stabilityThreshold: 2000,
88
96
  ignored: [...new Set([...WORKSPACE_INTERNAL_EXCLUSIONS, ...DEFAULT_SYNC_EXCLUSIONS, ...hubExclusions, ...MIRROR_IGNORE_DEFAULTS, ...(this.#mirror.ignore || [])])],
89
97
  });
90
98
  this.#stored.addBackend('trash', { driver: 'file', root: path.join(internal, 'trash'), watch: false });
@@ -98,7 +106,7 @@ export class MirrorRuntime {
98
106
  this.#engine = new Mirror(this.#stored, {
99
107
  id: this.#mirror.id, local: 'local', remote: 'canvas:hub', trash: 'trash', conflicts: 'conflicts',
100
108
  prefixes: this.#mirror.pins || [], ignore: this.#mirror.ignore || [],
101
- deletes: this.#mirror.deletes || 'propagate', conflictMode: this.#mirror.conflicts || 'prompt',
109
+ deletes: this.#mirror.deletes || 'propagate', conflictMode: this.#mirror.conflicts || 'rename',
102
110
  direction: this.#mirror.direction || 'bi',
103
111
  deviceId: this.#identity.deviceId, deviceName: this.#identity.deviceName,
104
112
  });
@@ -109,6 +117,7 @@ export class MirrorRuntime {
109
117
  this.#engine.on('revert', (e) => this.#logger.info({ mirror: this.id, key: e?.key, copy: e?.copy }, 'local edit reverted (pull mirror), copy kept'));
110
118
  this.#engine.on('skip', (e) => this.#logger.info({ mirror: this.id, key: e?.key, reason: e?.reason }, 'key skipped'));
111
119
  this.#engine.on('conflict', (c) => this.#logger.info({ mirror: this.id, key: c?.key }, 'conflict recorded'));
120
+ this.#engine.on('hold', (h) => this.#logger.warn({ mirror: this.id, files: h?.count }, 'many files are missing locally — deletions held; approve or restore with `canvas remote mirror deletes`'));
112
121
  await this.#engine.start();
113
122
  await this.#connectSocket();
114
123
  this.#reporter = setInterval(() => this.#report().catch(() => {}), STATUS_HEARTBEAT_MS);
@@ -135,7 +144,9 @@ export class MirrorRuntime {
135
144
  }
136
145
 
137
146
  nudge() { this.#engine?.nudge?.(); }
138
- async resync() { return this.#engine?.reconcileAll?.(); }
147
+ heldDeletes() { return this.#engine?.heldDeletes() ?? []; }
148
+ approveDeletes() { return this.#engine?.approveDeletes() ?? 0; }
149
+ restoreDeletes() { return this.#engine?.restoreDeletes() ?? 0; }
139
150
 
140
151
  async #fetchHubExclusions() {
141
152
  try {