@augmentd-labs/canvas-edge 0.0.0-stage → 0.4.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 (44) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +114 -0
  3. package/README.md +76 -2
  4. package/bin/canvas-edge +2 -0
  5. package/bin/healthcheck.mjs +9 -0
  6. package/node_modules/canvas-stored/LICENSE +661 -0
  7. package/node_modules/canvas-stored/NOTICE +85 -0
  8. package/node_modules/canvas-stored/README.md +472 -0
  9. package/node_modules/canvas-stored/package.json +26 -0
  10. package/node_modules/canvas-stored/src/backends/BackendManager.js +60 -0
  11. package/node_modules/canvas-stored/src/backends/StorageBackend.js +72 -0
  12. package/node_modules/canvas-stored/src/backends/cacache/index.js +130 -0
  13. package/node_modules/canvas-stored/src/backends/canvas/index.js +722 -0
  14. package/node_modules/canvas-stored/src/backends/file/index.js +658 -0
  15. package/node_modules/canvas-stored/src/backends/gdrive/index.js +924 -0
  16. package/node_modules/canvas-stored/src/backends/http/index.js +47 -0
  17. package/node_modules/canvas-stored/src/backends/s3/index.js +25 -0
  18. package/node_modules/canvas-stored/src/cache/index.js +65 -0
  19. package/node_modules/canvas-stored/src/extractors/audio.js +53 -0
  20. package/node_modules/canvas-stored/src/extractors/image.js +90 -0
  21. package/node_modules/canvas-stored/src/extractors/index.js +41 -0
  22. package/node_modules/canvas-stored/src/extractors/text.js +65 -0
  23. package/node_modules/canvas-stored/src/index/index.js +360 -0
  24. package/node_modules/canvas-stored/src/index.js +2058 -0
  25. package/node_modules/canvas-stored/src/sync/JobQueue.js +0 -0
  26. package/node_modules/canvas-stored/src/sync/Ledger.js +138 -0
  27. package/node_modules/canvas-stored/src/sync/Mirror.js +995 -0
  28. package/node_modules/canvas-stored/src/sync/SyncQueue.js +111 -0
  29. package/node_modules/canvas-stored/src/sync/keys.js +140 -0
  30. package/node_modules/canvas-stored/src/sync/worker.js +52 -0
  31. package/node_modules/canvas-stored/src/utils/checksum.js +32 -0
  32. package/node_modules/canvas-stored/src/utils/common.js +37 -0
  33. package/node_modules/canvas-stored/src/utils/mime.js +107 -0
  34. package/node_modules/canvas-stored/src/utils/mount.js +99 -0
  35. package/package.json +49 -4
  36. package/src/EdgeClient.js +129 -0
  37. package/src/control.js +48 -0
  38. package/src/daemon.js +88 -0
  39. package/src/env.js +182 -0
  40. package/src/fuse-runtime.js +173 -0
  41. package/src/index.js +26 -0
  42. package/src/mirror-runtime.js +232 -0
  43. package/src/remote-config.js +69 -0
  44. package/src/runtime.js +61 -0
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ import { io } from 'socket.io-client';
4
+
5
+ const CHUNK_SIZE = 256 * 1024;
6
+
7
+ /**
8
+ * canvas-edge tunnel client.
9
+ *
10
+ * Dials out to a canvas-server instance, announces what this runtime hosts,
11
+ * and replays proxied requests into the local fastify app (`app.inject()`),
12
+ * so the tunnel and localhost serve identical APIs by construction.
13
+ * Reconnection/backoff and heartbeat come from socket.io.
14
+ *
15
+ * See docs/canvas-edge-protocol.md for the frame protocol.
16
+ */
17
+ export default class EdgeClient {
18
+ #serverUrl;
19
+ #token;
20
+ #localApp;
21
+ #announce;
22
+ #socket = null;
23
+ #announced = false;
24
+ #forwarded = [];
25
+
26
+ constructor({ serverUrl, token, localApp, announce }) {
27
+ if (!serverUrl || !token || !localApp?.inject || !announce?.instanceId) {
28
+ throw new Error('EdgeClient requires serverUrl, token, localApp (fastify) and announce.instanceId');
29
+ }
30
+ this.#serverUrl = serverUrl.replace(/\/+$/, '');
31
+ this.#token = token;
32
+ this.#localApp = localApp;
33
+ this.#announce = announce;
34
+ }
35
+
36
+ /**
37
+ * One-time pairing: exchange a user API token for a device token via the
38
+ * existing device registration endpoint. Persist the result locally and
39
+ * never touch the user token again.
40
+ */
41
+ static async pair({ serverUrl, userToken, name, type = 'edge', ...deviceInfo }) {
42
+ const res = await fetch(`${serverUrl.replace(/\/+$/, '')}/rest/v2/auth/devices/register`, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userToken}` },
45
+ body: JSON.stringify({ name, type, ...deviceInfo }),
46
+ });
47
+ const json = await res.json().catch(() => null);
48
+ if (!res.ok || !json?.payload?.token) {
49
+ throw new Error(`Edge pairing failed: ${json?.message || res.statusText}`);
50
+ }
51
+ return { token: json.payload.token, deviceId: json.payload.deviceId || json.payload.id };
52
+ }
53
+
54
+ get connected() {
55
+ return this.#socket?.connected === true;
56
+ }
57
+
58
+ connect() {
59
+ if (this.#socket) return this;
60
+ this.#socket = io(this.#serverUrl, {
61
+ auth: { token: this.#token },
62
+ transports: ['websocket'],
63
+ });
64
+ // Announce on every (re)connect — announce is idempotent full state.
65
+ this.#socket.on('connect', () => this.#socket.emit('edge:announce', this.#announce));
66
+ this.#socket.on('edge:announced', () => { this.#announced = true; });
67
+ this.#socket.on('disconnect', () => { this.#announced = false; });
68
+ this.#socket.on('edge:req', (frame) => this.handleRequest(frame));
69
+ return this;
70
+ }
71
+
72
+ /** Resolves once the server has acked the announce; rejects on timeout. */
73
+ waitForAnnounce(timeoutMs = 10_000) {
74
+ if (this.#announced) return Promise.resolve();
75
+ if (!this.#socket) return Promise.reject(new Error('not connected — call connect() first'));
76
+ return new Promise((resolve, reject) => {
77
+ const timer = setTimeout(() => {
78
+ this.#socket?.off('edge:announced', onAck);
79
+ reject(new Error(`edge announce not acked within ${timeoutMs}ms`));
80
+ }, timeoutMs);
81
+ const onAck = () => { clearTimeout(timer); resolve(); };
82
+ this.#socket.once('edge:announced', onAck);
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Dispatch a proxied request into the local app and stream the answer back.
88
+ * `socket` is injectable for tests.
89
+ */
90
+ async handleRequest(frame = {}, socket = this.#socket) {
91
+ const { id, method, path, headers = {}, body, bodyEncoding } = frame;
92
+ if (!id || !method || !path) return;
93
+ try {
94
+ const payload = body == null
95
+ ? undefined
96
+ : (bodyEncoding === 'base64' ? Buffer.from(body, 'base64') : body);
97
+ const res = await this.#localApp.inject({ method, url: path, headers, payload });
98
+ socket.emit('edge:res', { id, status: res.statusCode, headers: res.headers });
99
+ const buf = res.rawPayload;
100
+ for (let offset = 0, seq = 0; offset < buf.length; offset += CHUNK_SIZE, seq++) {
101
+ socket.emit('edge:chunk', { id, seq, data: buf.subarray(offset, offset + CHUNK_SIZE).toString('base64') });
102
+ }
103
+ socket.emit('edge:end', { id });
104
+ } catch (err) {
105
+ socket.emit('edge:err', { id, code: 'EDGE_DISPATCH_FAILED', message: err.message });
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Relay all events from a local wildcard emitter (EventEmitter2) up the
111
+ * tunnel; the server re-emits them through its WorkspaceManager.
112
+ */
113
+ forwardEvents(emitter) {
114
+ const socket = () => this.#socket;
115
+ const listener = function (payload) {
116
+ socket()?.emit('edge:event', { name: this.event, payload });
117
+ };
118
+ emitter.on('**', listener);
119
+ this.#forwarded.push([emitter, listener]);
120
+ return this;
121
+ }
122
+
123
+ close() {
124
+ for (const [emitter, listener] of this.#forwarded) emitter.off('**', listener);
125
+ this.#forwarded = [];
126
+ this.#socket?.disconnect();
127
+ this.#socket = null;
128
+ }
129
+ }
package/src/control.js ADDED
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ import fs from 'fs';
4
+ import Fastify from 'fastify';
5
+ import { EDGE_PATHS } from './env.js';
6
+
7
+ /**
8
+ * Local control API for the CLI (unix socket, 0600; a localhost port on
9
+ * Windows). Nothing here is reachable from the network.
10
+ */
11
+ export async function startControl({ runtimes, reload, shutdown, logger }) {
12
+ const app = Fastify({ logger: false });
13
+ const byId = (id) => runtimes.get(id) || [...runtimes.values()].find((r) => r.status().workspace === id) || null;
14
+
15
+ // fuse units read `canvas-fuse status --json` (a process); refresh them before answering.
16
+ const snapshot = async () => {
17
+ for (const r of runtimes.values()) if (typeof r.refresh === 'function') await r.refresh().catch(() => null);
18
+ return [...runtimes.values()].map((r) => r.status());
19
+ };
20
+ app.get('/status', async () => ({ pid: process.pid, mirrors: await snapshot() }));
21
+ app.get('/mirrors', async () => snapshot());
22
+ app.post('/mirrors/:id/resync', async (req, reply) => {
23
+ const r = byId(req.params.id);
24
+ if (!r) return reply.code(404).send({ error: 'no such mirror' });
25
+ await r.resync();
26
+ return r.status();
27
+ });
28
+ app.post('/mirrors/:id/nudge', async (req, reply) => {
29
+ const r = byId(req.params.id);
30
+ if (!r) return reply.code(404).send({ error: 'no such mirror' });
31
+ r.nudge();
32
+ return { ok: true };
33
+ });
34
+ app.post('/reload', async () => { await reload(); return { ok: true, mirrors: runtimes.size }; });
35
+ app.post('/shutdown', async () => { setTimeout(() => shutdown(), 50); return { ok: true }; });
36
+
37
+ if (EDGE_PATHS.socket) {
38
+ fs.mkdirSync(EDGE_PATHS.run, { recursive: true });
39
+ try { fs.unlinkSync(EDGE_PATHS.socket); } catch { /* absent */ }
40
+ await app.listen({ path: EDGE_PATHS.socket });
41
+ fs.chmodSync(EDGE_PATHS.socket, 0o600);
42
+ logger.info({ socket: EDGE_PATHS.socket }, 'control socket ready');
43
+ } else {
44
+ await app.listen({ host: '127.0.0.1', port: EDGE_PATHS.port });
45
+ logger.info({ port: EDGE_PATHS.port }, 'control port ready');
46
+ }
47
+ return app;
48
+ }
package/src/daemon.js ADDED
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import pino from 'pino';
6
+ import { EDGE_PATHS, STATE_ROOT, daemonMirrors, deviceIdentity, ensureDeviceToken, ensureEnvConfig, fuseMirrors, hubFor } from './env.js';
7
+ import { MirrorRuntime } from './mirror-runtime.js';
8
+ import { FuseRuntime } from './fuse-runtime.js';
9
+ import { startControl } from './control.js';
10
+
11
+ /*
12
+ * canvas-edge: the device-side daemon for real-folder mirrors (the non-FUSE
13
+ * client of the sync protocol, for macOS/Windows and anyone who wants a plain
14
+ * folder). One process per device runs every `client: 'daemon'` mirror from
15
+ * ~/.canvas/config/mirrors.json; the CLI (`canvas mirror`) writes that file and
16
+ * talks to this process over the control socket.
17
+ */
18
+ export async function main(argv = process.argv.slice(2)) {
19
+ const foreground = argv.includes('--foreground') || argv.includes('-f');
20
+ fs.mkdirSync(path.dirname(EDGE_PATHS.log), { recursive: true });
21
+ const logger = foreground
22
+ ? pino({ level: process.env.LOG_LEVEL || 'info' })
23
+ : pino({ level: process.env.LOG_LEVEL || 'info' }, pino.destination({ dest: EDGE_PATHS.log, sync: false }));
24
+
25
+ const runtimes = new Map();
26
+
27
+ // Container / env-driven setup: one remote + one mirror from the environment.
28
+ try {
29
+ const seeded = ensureEnvConfig();
30
+ if (seeded) logger.info({ mirror: seeded.id, folder: seeded.mountpoint, direction: seeded.direction, stateRoot: STATE_ROOT }, 'mirror configured from environment');
31
+ } catch (err) {
32
+ logger.error({ err: err?.message }, 'env-driven mirror config failed');
33
+ }
34
+
35
+ const load = async () => {
36
+ const wanted = daemonMirrors();
37
+ const fuse = fuseMirrors();
38
+ const wantedIds = new Set([...wanted, ...fuse].map((m) => m.id));
39
+ for (const [id, rt] of runtimes) {
40
+ if (!wantedIds.has(id)) { await rt.stop().catch(() => {}); runtimes.delete(id); logger.info({ mirror: id }, 'mirror stopped (removed from config)'); }
41
+ }
42
+ for (const mirror of wanted) {
43
+ if (runtimes.has(mirror.id)) continue;
44
+ let hub = hubFor(mirror.remote);
45
+ if (!hub?.token) { logger.warn({ mirror: mirror.id, remote: mirror.remote }, 'no credentials for hub — skipped'); continue; }
46
+ if (!hub.deviceId) {
47
+ try { hub = await ensureDeviceToken(mirror.remote, { logger }); }
48
+ catch (err) { logger.warn({ mirror: mirror.id, remote: mirror.remote, err: err?.message }, 'device registration failed — reporting under the user token'); }
49
+ }
50
+ const identity = deviceIdentity(hub);
51
+ const rt = new MirrorRuntime({ mirror, hub, identity, logger });
52
+ try {
53
+ await rt.start();
54
+ runtimes.set(mirror.id, rt);
55
+ } catch (err) {
56
+ logger.error({ mirror: mirror.id, err: err?.message }, 'mirror failed to start');
57
+ }
58
+ }
59
+ // fuse units: canvas-fuse mounts this daemon supervises (one process per workspace).
60
+ for (const mirror of fuse) {
61
+ if (runtimes.has(mirror.id)) continue;
62
+ const rt = new FuseRuntime({ mirror, logger });
63
+ try {
64
+ await rt.start();
65
+ runtimes.set(mirror.id, rt);
66
+ } catch (err) {
67
+ logger.error({ unit: mirror.id, err: err?.message }, 'fuse unit failed to start');
68
+ }
69
+ }
70
+ };
71
+
72
+ let stopping = false;
73
+ const shutdown = async () => {
74
+ if (stopping) return;
75
+ stopping = true;
76
+ logger.info('shutting down');
77
+ for (const rt of runtimes.values()) await rt.stop().catch(() => {});
78
+ await control?.close().catch(() => {});
79
+ process.exit(0);
80
+ };
81
+ process.on('SIGTERM', shutdown);
82
+ process.on('SIGINT', shutdown);
83
+ process.on('SIGHUP', () => load().catch((err) => logger.error({ err: err?.message }, 'reload failed')));
84
+
85
+ const control = await startControl({ runtimes, reload: load, shutdown, logger });
86
+ await load();
87
+ logger.info({ device: deviceIdentity().deviceId, mirrors: runtimes.size, fuse: [...runtimes.values()].filter((r) => r.unit === 'fuse').length }, 'canvas-edge running');
88
+ }
package/src/env.js ADDED
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import EdgeClient from './EdgeClient.js';
7
+
8
+ /*
9
+ * The daemon's own home. Deliberately NOT src/env.js: there CANVAS_USER_HOME
10
+ * means the server's users root, while for every client (cli, canvas-fuse,
11
+ * desktop) it is the per-user config home (~/.canvas). The daemon is a client.
12
+ */
13
+ export const EDGE_HOME = process.env.CANVAS_EDGE_HOME
14
+ || process.env.CANVAS_USER_HOME
15
+ || (process.platform === 'win32' ? path.join(os.homedir(), 'Canvas') : path.join(os.homedir(), '.canvas'));
16
+
17
+ export const EDGE_PATHS = Object.freeze({
18
+ home: EDGE_HOME,
19
+ mirrors: path.join(EDGE_HOME, 'config', 'mirrors.json'),
20
+ remotes: path.join(EDGE_HOME, 'config', 'remotes.json'),
21
+ device: process.env.CANVAS_DEVICE_FILE || (process.platform === 'win32'
22
+ ? path.join(os.homedir(), 'Canvas', 'device.json')
23
+ : path.join(os.homedir(), '.canvas', 'device.json')),
24
+ run: path.join(EDGE_HOME, 'run'),
25
+ socket: process.platform === 'win32' ? null : path.join(EDGE_HOME, 'run', 'edge.sock'),
26
+ port: Number(process.env.CANVAS_EDGE_PORT) || 8802,
27
+ log: path.join(EDGE_HOME, 'var', 'log', 'canvas-edge.log'),
28
+ });
29
+
30
+ export function readJson(file, fallback = null) {
31
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
32
+ }
33
+
34
+ function writeJson(file, value) {
35
+ fs.mkdirSync(path.dirname(file), { recursive: true });
36
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
37
+ }
38
+
39
+ /*
40
+ * External state (docs/durable-workspaces.md, step 4). A mirror keeps its
41
+ * ledger, queue, cache, trash and conflicts under `<folder>/.workspace/` by
42
+ * default. A backup target (a NAS share) should hold user files only, so a
43
+ * mirror may name a `stateDir`, or the daemon may be given a root under
44
+ * which every mirror gets `<root>/<mirror id>` (the container sets
45
+ * CANVAS_EDGE_STATE_ROOT=/state).
46
+ */
47
+ export const STATE_ROOT = process.env.CANVAS_EDGE_STATE_ROOT ? path.resolve(process.env.CANVAS_EDGE_STATE_ROOT) : null;
48
+
49
+ export function resolveStateDir(mirror, stateRoot = STATE_ROOT) {
50
+ if (mirror?.stateDir) return path.resolve(String(mirror.stateDir));
51
+ if (stateRoot && mirror?.id) return path.join(stateRoot, String(mirror.id).replace(/[^a-zA-Z0-9._@-]+/g, '_'));
52
+ return null; // null = inside the folder (`<folder>/.workspace`)
53
+ }
54
+
55
+ /*
56
+ * Env-driven single-mirror setup (the container's way in): with
57
+ * CANVAS_HUB_URL + CANVAS_HUB_TOKEN + CANVAS_WORKSPACE set, the daemon writes
58
+ * (or refreshes) one remote and one daemon mirror in its own config files
59
+ * before loading them. Idempotent: matched by id, only env-driven fields are
60
+ * overwritten, everything else the files carry is kept.
61
+ *
62
+ * CANVAS_HUB_URL https://canvas.example.org
63
+ * CANVAS_HUB_TOKEN user/API, device or workspace token
64
+ * CANVAS_HUB_ID remote id (default "hub")
65
+ * CANVAS_WORKSPACE workspace name (as in the hub URL)
66
+ * CANVAS_MIRROR_PATH folder to mirror (default /data)
67
+ * CANVAS_DIRECTION bi | pull | push (default bi)
68
+ * CANVAS_CONFLICTS prompt | rename CANVAS_DELETES propagate | keep
69
+ * CANVAS_PINS / CANVAS_IGNORE comma-separated globs
70
+ */
71
+ export function ensureEnvConfig(env = process.env) {
72
+ const url = env.CANVAS_HUB_URL && String(env.CANVAS_HUB_URL).trim().replace(/\/+$/, '');
73
+ const token = env.CANVAS_HUB_TOKEN && String(env.CANVAS_HUB_TOKEN).trim();
74
+ const workspace = env.CANVAS_WORKSPACE && String(env.CANVAS_WORKSPACE).trim();
75
+ if (!url || !token || !workspace) return null;
76
+ const hubId = (env.CANVAS_HUB_ID && String(env.CANVAS_HUB_ID).trim()) || 'hub';
77
+ const list = (v) => String(v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
78
+ const oneOf = (v, allowed, fallback) => (allowed.includes(String(v ?? '').trim()) ? String(v).trim() : fallback);
79
+
80
+ const remotes = readJson(EDGE_PATHS.remotes, {}) || {};
81
+ const prior = remotes[hubId] || {};
82
+ // A device token minted for this remote stays authoritative; env only
83
+ // seeds the user/API token it started from.
84
+ remotes[hubId] = { ...prior, url, apiBase: prior.apiBase || '/rest/v2', auth: { ...(prior.auth || {}), token }, source: 'env' };
85
+ writeJson(EDGE_PATHS.remotes, remotes);
86
+
87
+ const cfg = readJson(EDGE_PATHS.mirrors, { version: 1, mirrors: [] }) || { version: 1, mirrors: [] };
88
+ const mirrors = Array.isArray(cfg.mirrors) ? cfg.mirrors : [];
89
+ const id = `${hubId}/${workspace.toLowerCase()}`;
90
+ const existing = mirrors.find((m) => m?.id === id) || {};
91
+ const mirror = {
92
+ ...existing,
93
+ id,
94
+ remote: hubId,
95
+ workspaceId: existing.workspaceId || null,
96
+ workspaceName: workspace.toLowerCase(),
97
+ folderName: existing.folderName || workspace,
98
+ mountpoint: path.resolve(env.CANVAS_MIRROR_PATH ? String(env.CANVAS_MIRROR_PATH) : (existing.mountpoint || '/data')),
99
+ pins: env.CANVAS_PINS != null ? list(env.CANVAS_PINS) : (existing.pins || []),
100
+ ignore: env.CANVAS_IGNORE != null ? list(env.CANVAS_IGNORE) : (existing.ignore || []),
101
+ conflicts: oneOf(env.CANVAS_CONFLICTS, ['prompt', 'rename'], existing.conflicts || 'prompt'),
102
+ deletes: oneOf(env.CANVAS_DELETES, ['propagate', 'keep'], existing.deletes || 'propagate'),
103
+ direction: oneOf(env.CANVAS_DIRECTION, ['bi', 'pull', 'push'], existing.direction || 'bi'),
104
+ client: 'daemon',
105
+ managed: existing.managed || 'container',
106
+ paused: false,
107
+ createdAt: existing.createdAt || new Date().toISOString(),
108
+ source: 'env',
109
+ };
110
+ cfg.mirrors = [...mirrors.filter((m) => m?.id !== id), mirror];
111
+ writeJson(EDGE_PATHS.mirrors, { version: cfg.version || 1, ...cfg, mirrors: cfg.mirrors });
112
+ return mirror;
113
+ }
114
+
115
+ /**
116
+ * Who this daemon is towards a hub. A device token is minted for one device
117
+ * id, so when remotes.json holds one, THAT id is the identity (the hub rejects
118
+ * status reports under any other); device.json is the fallback for
119
+ * user/API tokens, then a stable host-derived id.
120
+ */
121
+ export function deviceIdentity(hub = null) {
122
+ const rec = readJson(EDGE_PATHS.device, null);
123
+ const name = process.env.CANVAS_DEVICE_NAME || rec?.hostname || os.hostname();
124
+ if (hub?.deviceId) return { deviceId: String(hub.deviceId), deviceName: name };
125
+ // A container's hostname is random per run: pin the identity explicitly.
126
+ if (process.env.CANVAS_DEVICE_ID) return { deviceId: String(process.env.CANVAS_DEVICE_ID).replace(/[^a-zA-Z0-9._-]+/g, '-'), deviceName: name };
127
+ if (rec?.deviceId) return { deviceId: String(rec.deviceId), deviceName: name };
128
+ return { deviceId: `host-${os.hostname()}-${os.userInfo().username}`.replace(/[^a-zA-Z0-9._-]+/g, '-'), deviceName: name };
129
+ }
130
+
131
+ /*
132
+ * An env-seeded remote starts with a user/API token. The hub keys mirror
133
+ * status (and replica protection) by DEVICE, so on first contact the daemon
134
+ * registers itself (`POST /auth/devices/register`) under its CANVAS_DEVICE_ID
135
+ * and keeps the device token in remotes.json; from then on hubFor() presents
136
+ * that token and the hub sees one stable device. CLI-managed remotes already
137
+ * carry a device token and are left alone.
138
+ */
139
+ export async function ensureDeviceToken(remoteId, { logger = null } = {}) {
140
+ const remotes = readJson(EDGE_PATHS.remotes, {}) || {};
141
+ const r = remotes[remoteId];
142
+ if (!r?.url || r.source !== 'env' || r.device?.token || !r.auth?.token) return hubFor(remoteId);
143
+ const identity = deviceIdentity(null);
144
+ const paired = await EdgeClient.pair({
145
+ serverUrl: r.url, userToken: r.auth.token, name: identity.deviceName, type: 'edge',
146
+ deviceId: identity.deviceId, hostname: os.hostname(), platform: process.platform, arch: process.arch,
147
+ });
148
+ remotes[remoteId] = { ...r, device: { deviceId: paired.deviceId || identity.deviceId, token: paired.token, registeredAt: new Date().toISOString() } };
149
+ writeJson(EDGE_PATHS.remotes, remotes);
150
+ logger?.info?.({ remote: remoteId, deviceId: remotes[remoteId].device.deviceId }, 'registered as a device on the hub');
151
+ return hubFor(remoteId);
152
+ }
153
+
154
+ /** Hub url + token (+ the device id the token belongs to) for a remote id. */
155
+ export function hubFor(remoteId) {
156
+ const remotes = readJson(EDGE_PATHS.remotes, {}) || {};
157
+ const r = remotes[remoteId];
158
+ if (!r?.url) return null;
159
+ const deviceToken = r.device?.token || null;
160
+ const token = deviceToken || r.auth?.token || null;
161
+ return {
162
+ id: remoteId,
163
+ url: String(r.url).replace(/\/+$/, ''),
164
+ apiBase: r.apiBase || '/rest/v2',
165
+ token,
166
+ deviceId: deviceToken && r.device?.deviceId ? String(r.device.deviceId) : null,
167
+ };
168
+ }
169
+
170
+ /** FUSE mounts this daemon supervises: `client: 'fuse', managed: 'edge'` entries (docs/durable-workspaces.md step 5). */
171
+ export function fuseMirrors() {
172
+ const cfg = readJson(EDGE_PATHS.mirrors, { mirrors: [] }) || {};
173
+ return (Array.isArray(cfg.mirrors) ? cfg.mirrors : []).filter((m) => m && m.client === 'fuse' && m.managed === 'edge' && !m.paused);
174
+ }
175
+
176
+ /** Mirrors this daemon owns: `client: 'daemon'` entries of the CLI's mirrors.json. */
177
+ export function daemonMirrors() {
178
+ const cfg = readJson(EDGE_PATHS.mirrors, { mirrors: [] }) || {};
179
+ return (Array.isArray(cfg.mirrors) ? cfg.mirrors : [])
180
+ .filter((m) => m && m.client === 'daemon' && !m.paused)
181
+ .map((m) => ({ ...m, stateDir: resolveStateDir(m) }));
182
+ }
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { promisify } from 'node:util';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ /**
12
+ * A `fuse` unit: one canvas-fuse mirror mount supervised by this daemon
13
+ * (docs/durable-workspaces.md, step 5). canvas-fuse stays a plain executable —
14
+ * the on-demand namespace, content cache, pins and LRU are its own; this class
15
+ * only spawns `canvas-fuse mount … --mirror` attached, restarts it with backoff
16
+ * when it dies, reads `canvas-fuse status --json` for its state and unmounts
17
+ * on stop. One process per workspace under a common root (`<root>/<folder>`),
18
+ * several workspaces = several units in mirrors.json (`client: 'fuse',
19
+ * managed: 'edge'`). Credentials never touch the command line: the mount
20
+ * resolves `--remote <id>` from ~/.canvas/config/remotes.json, like the CLI.
21
+ */
22
+
23
+ const DEFAULT_RESTART_DELAYS_MS = [5_000, 10_000, 20_000, 40_000, 60_000];
24
+ const STATUS_TTL_MS = 5_000;
25
+
26
+ export function fuseBinary(env = process.env) {
27
+ const candidates = [env.CANVAS_FUSE_BIN, path.join(os.homedir(), '.cargo', 'bin', 'canvas-fuse')].filter(Boolean);
28
+ for (const c of candidates) if (existsSync(c)) return c;
29
+ return 'canvas-fuse'; // PATH lookup at spawn time
30
+ }
31
+
32
+ /** Command line for one mirror entry (attached: no `-d`, the daemon owns the lifecycle). */
33
+ export function fuseMountArgs(mirror) {
34
+ if (!mirror?.root) throw new Error(`fuse unit ${mirror?.id || '?'}: mirror.root is required`);
35
+ if (!mirror?.remote) throw new Error(`fuse unit ${mirror?.id || '?'}: mirror.remote is required`);
36
+ const args = ['mount', '-w', mirror.folderName || mirror.workspaceName, mirror.root, '--remote', mirror.remote, '--mirror'];
37
+ for (const pin of mirror.pins || []) args.push('--pin', pin);
38
+ for (const glob of mirror.ignore || []) args.push('--ignore', glob);
39
+ if (mirror.conflicts) args.push('--conflicts', mirror.conflicts);
40
+ if (mirror.deletes) args.push('--deletes', mirror.deletes);
41
+ if (mirror.cacheBudgetMb) args.push('--cache-budget-mb', String(mirror.cacheBudgetMb));
42
+ return args;
43
+ }
44
+
45
+ export class FuseRuntime {
46
+ #mirror;
47
+ #logger;
48
+ #bin;
49
+ #child = null;
50
+ #stopping = false;
51
+ #restartTimer = null;
52
+ #restarts = 0;
53
+ #attempt = 0;
54
+ #startedAt = null;
55
+ #lastExit = null;
56
+ #restartDelays;
57
+ #statusCache = null;
58
+
59
+ constructor({ mirror, logger, binary = null, restartDelaysMs = DEFAULT_RESTART_DELAYS_MS }) {
60
+ this.#mirror = mirror;
61
+ this.#logger = logger;
62
+ this.#bin = binary || fuseBinary();
63
+ this.#restartDelays = restartDelaysMs;
64
+ }
65
+
66
+ get id() { return this.#mirror.id; }
67
+ get unit() { return 'fuse'; }
68
+ get folder() { return this.#mirror.mountpoint || path.join(this.#mirror.root, this.#mirror.folderName || this.#mirror.workspaceName); }
69
+ get running() { return !!this.#child && this.#child.exitCode == null && !this.#child.killed; }
70
+ get pid() { return this.running ? this.#child.pid : null; }
71
+
72
+ async start() {
73
+ this.#stopping = false;
74
+ await this.#spawn();
75
+ this.#logger.info({ unit: this.id, mountpoint: this.folder, bin: this.#bin }, 'fuse unit started');
76
+ }
77
+
78
+ async #spawn() {
79
+ const args = fuseMountArgs(this.#mirror);
80
+ const child = spawn(this.#bin, args, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
81
+ this.#child = child;
82
+ this.#startedAt = Date.now();
83
+ const log = (level) => (chunk) => { for (const line of String(chunk).split('\n')) if (line.trim()) this.#logger[level]?.({ unit: this.id }, line.trim()); };
84
+ child.stdout?.on('data', log('debug'));
85
+ child.stderr?.on('data', log('debug'));
86
+ await new Promise((resolve, reject) => {
87
+ child.once('spawn', resolve);
88
+ child.once('error', (err) => reject(Object.assign(new Error(err.code === 'ENOENT' ? `canvas-fuse binary not found (${this.#bin}); install it or set CANVAS_FUSE_BIN` : err.message), { code: err.code })));
89
+ });
90
+ child.on('exit', (code, signal) => {
91
+ if (this.#child !== child) return;
92
+ this.#child = null;
93
+ this.#lastExit = { code, signal, at: Date.now() };
94
+ this.#statusCache = null;
95
+ if (this.#stopping) return;
96
+ // A mount that stayed up a while starts the backoff over.
97
+ if (Date.now() - this.#startedAt > 60_000) this.#attempt = 0;
98
+ const delay = this.#restartDelays[Math.min(this.#attempt, this.#restartDelays.length - 1)];
99
+ this.#attempt += 1;
100
+ this.#restarts += 1;
101
+ this.#logger.warn({ unit: this.id, code, signal, restartInMs: delay }, 'fuse mount exited — restarting');
102
+ this.#restartTimer = setTimeout(() => {
103
+ this.#restartTimer = null;
104
+ this.#spawn().catch((err) => this.#logger.error({ unit: this.id, err: err?.message }, 'fuse restart failed'));
105
+ }, delay);
106
+ this.#restartTimer.unref?.();
107
+ });
108
+ }
109
+
110
+ /** Graceful: SIGINT (canvas-fuse unmounts on it), then SIGTERM, then a best-effort `unmount`. */
111
+ async stop({ timeoutMs = 10_000 } = {}) {
112
+ this.#stopping = true;
113
+ if (this.#restartTimer) { clearTimeout(this.#restartTimer); this.#restartTimer = null; }
114
+ const child = this.#child;
115
+ if (child && child.exitCode == null) {
116
+ const exited = new Promise((resolve) => child.once('exit', resolve));
117
+ child.kill('SIGINT');
118
+ const timer = new Promise((resolve) => setTimeout(resolve, timeoutMs));
119
+ if (await Promise.race([exited.then(() => true), timer.then(() => false)]) === false) {
120
+ child.kill('SIGTERM');
121
+ await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 3_000))]);
122
+ }
123
+ }
124
+ this.#child = null;
125
+ await this.#run(['unmount', this.folder], { timeout: 15_000 }).catch(() => null);
126
+ this.#logger.info({ unit: this.id, mountpoint: this.folder }, 'fuse unit stopped');
127
+ }
128
+
129
+ async #run(args, { timeout = 30_000 } = {}) {
130
+ const { stdout } = await execFileAsync(this.#bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
131
+ return String(stdout || '');
132
+ }
133
+
134
+ /** The mount's own account (`canvas-fuse status --json` row for this mountpoint), cached briefly. */
135
+ async mountStatus() {
136
+ if (this.#statusCache && Date.now() - this.#statusCache.at < STATUS_TTL_MS) return this.#statusCache.row;
137
+ let row = null;
138
+ try {
139
+ const parsed = JSON.parse(await this.#run(['status', '--json'], { timeout: 15_000 }) || '[]');
140
+ const rows = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.mounts) ? parsed.mounts : []);
141
+ const target = path.resolve(this.folder);
142
+ row = rows.find((r) => path.resolve(r?.mountpoint || '') === target) || null;
143
+ } catch { row = null; }
144
+ this.#statusCache = { at: Date.now(), row };
145
+ return row;
146
+ }
147
+
148
+ /** Sync snapshot; `status()` is what the control API and the CLI show. */
149
+ status() {
150
+ const row = this.#statusCache?.row || null;
151
+ return {
152
+ id: this.id,
153
+ unit: 'fuse',
154
+ workspace: this.#mirror.workspaceName,
155
+ hub: this.#mirror.remote,
156
+ folder: this.folder,
157
+ pins: this.#mirror.pins || [],
158
+ conflictsMode: this.#mirror.conflicts,
159
+ direction: 'bi',
160
+ pid: this.pid,
161
+ running: this.running,
162
+ restarts: this.#restarts,
163
+ startedAt: this.#startedAt,
164
+ lastExit: this.#lastExit,
165
+ mount: row ? (row.status || (row.mounted ? 'ok' : 'down')) : (this.running ? 'starting' : 'down'),
166
+ ...(row?.mirror && typeof row.mirror === 'object' ? row.mirror : {}),
167
+ };
168
+ }
169
+
170
+ async refresh() { await this.mountStatus(); return this.status(); }
171
+ nudge() { this.#run(['sync', 'now', this.folder]).catch(() => null); }
172
+ async resync() { await this.#run(['sync', 'now', this.folder]).catch(() => null); return this.status(); }
173
+ }
package/src/index.js ADDED
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @augmentd-labs/canvas-edge — the device-side Canvas runtime.
5
+ *
6
+ * Two jobs today, one process (`bin/canvas-edge`):
7
+ * mirror — keep real folders in sync with hub workspaces (canvas-stored's
8
+ * Mirror engine; state under <folder>/.workspace/)
9
+ * tunnel — EdgeClient dials out to a canvas-server, announces what this
10
+ * runtime hosts and replays proxied requests into a local fastify
11
+ * app, so a remote workspace/agent behaves as if server-local
12
+ *
13
+ * Zero canvas-server imports on purpose: the same package backs `ws`,
14
+ * canvas-agentd and the desktop sidecar. The hub side of the tunnel
15
+ * (EdgeRegistry) lives in canvas-server. Protocol: canvas-server
16
+ * docs/canvas-edge-protocol.md; sync wire contract: docs/sync-protocol.md.
17
+ */
18
+
19
+ export { default as EdgeClient } from './EdgeClient.js';
20
+ export { connectRemotes, buildAnnounce } from './runtime.js';
21
+ export { readWorkspaceConfig, listRemotes, saveRemote, removeRemote } from './remote-config.js';
22
+ export { EDGE_HOME, EDGE_PATHS, deviceIdentity, hubFor, daemonMirrors, fuseMirrors } from './env.js';
23
+ export { MirrorRuntime } from './mirror-runtime.js';
24
+ export { FuseRuntime, fuseMountArgs, fuseBinary } from './fuse-runtime.js';
25
+ export { startControl } from './control.js';
26
+ export { main } from './daemon.js';