@artenx/dshb 0.1.18 → 0.1.20

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 (112) hide show
  1. package/lib/auth/decorate-webserver.d.ts +2 -1
  2. package/lib/auth/decorate-webserver.js +21 -10
  3. package/lib/auth/decorate-webserver.js.map +1 -1
  4. package/lib/auth/gate.d.ts +3 -1
  5. package/lib/auth/gate.js +18 -1
  6. package/lib/auth/gate.js.map +1 -1
  7. package/lib/auth/index.js +11 -3
  8. package/lib/auth/index.js.map +1 -1
  9. package/lib/auth/webserver.d.ts +3 -1
  10. package/lib/auth/webserver.js +10 -1
  11. package/lib/auth/webserver.js.map +1 -1
  12. package/lib/client.js +2157 -567
  13. package/lib/client.js.map +1 -1
  14. package/lib/core/index.js +72 -4
  15. package/lib/core/index.js.map +1 -1
  16. package/lib/core/node-registry.d.ts +2 -108
  17. package/lib/core/node-registry.js +43 -2
  18. package/lib/core/node-registry.js.map +1 -1
  19. package/lib/core/routes.d.ts +4 -3
  20. package/lib/core/routes.js +67 -23
  21. package/lib/core/routes.js.map +1 -1
  22. package/lib/core/sync/coordinator.d.ts +48 -0
  23. package/lib/core/sync/coordinator.js +247 -0
  24. package/lib/core/sync/coordinator.js.map +1 -0
  25. package/lib/core/sync/host-key.d.ts +2 -0
  26. package/lib/core/sync/host-key.js +71 -0
  27. package/lib/core/sync/host-key.js.map +1 -0
  28. package/lib/core/sync/internal-routes.d.ts +9 -0
  29. package/lib/core/sync/internal-routes.js +236 -0
  30. package/lib/core/sync/internal-routes.js.map +1 -0
  31. package/lib/core/sync/model.d.ts +2 -0
  32. package/lib/core/sync/model.js +106 -0
  33. package/lib/core/sync/model.js.map +1 -0
  34. package/lib/core/sync/node-sync.d.ts +2 -0
  35. package/lib/core/sync/node-sync.js +660 -0
  36. package/lib/core/sync/node-sync.js.map +1 -0
  37. package/lib/core/sync/offline-archive.d.ts +55 -0
  38. package/lib/core/sync/offline-archive.js +673 -0
  39. package/lib/core/sync/offline-archive.js.map +1 -0
  40. package/lib/core/sync/offline-journal.d.ts +22 -0
  41. package/lib/core/sync/offline-journal.js +74 -0
  42. package/lib/core/sync/offline-journal.js.map +1 -0
  43. package/lib/core/sync/offline-routes.d.ts +7 -0
  44. package/lib/core/sync/offline-routes.js +192 -0
  45. package/lib/core/sync/offline-routes.js.map +1 -0
  46. package/lib/core/sync/peer-manager.d.ts +56 -0
  47. package/lib/core/sync/peer-manager.js +258 -0
  48. package/lib/core/sync/peer-manager.js.map +1 -0
  49. package/lib/core/sync/protocol.d.ts +2 -0
  50. package/lib/core/sync/protocol.js +124 -0
  51. package/lib/core/sync/protocol.js.map +1 -0
  52. package/lib/core/sync/remote-helper.d.ts +5 -0
  53. package/lib/core/sync/remote-helper.js +160 -0
  54. package/lib/core/sync/remote-helper.js.map +1 -0
  55. package/lib/core/sync/revision.d.ts +2 -0
  56. package/lib/core/sync/revision.js +77 -0
  57. package/lib/core/sync/revision.js.map +1 -0
  58. package/lib/core/sync/routes.d.ts +9 -0
  59. package/lib/core/sync/routes.js +339 -0
  60. package/lib/core/sync/routes.js.map +1 -0
  61. package/lib/core/sync/scheduler.d.ts +14 -0
  62. package/lib/core/sync/scheduler.js +81 -0
  63. package/lib/core/sync/scheduler.js.map +1 -0
  64. package/lib/core/sync/session-journal.d.ts +2 -0
  65. package/lib/core/sync/session-journal.js +191 -0
  66. package/lib/core/sync/session-journal.js.map +1 -0
  67. package/lib/core/sync/session-routes.d.ts +7 -0
  68. package/lib/core/sync/session-routes.js +67 -0
  69. package/lib/core/sync/session-routes.js.map +1 -0
  70. package/lib/core/sync/session-sync.d.ts +2 -0
  71. package/lib/core/sync/session-sync.js +2 -0
  72. package/lib/core/sync/ssh-transport.d.ts +2 -0
  73. package/lib/core/sync/ssh-transport.js +408 -0
  74. package/lib/core/sync/ssh-transport.js.map +1 -0
  75. package/lib/core/sync/stores.d.ts +3 -0
  76. package/lib/core/sync/stores.js +385 -0
  77. package/lib/core/sync/stores.js.map +1 -0
  78. package/lib/core/test.d.ts +1 -1
  79. package/lib/core/workspace-bindings.d.ts +2 -28
  80. package/lib/core/workspace-bindings.js +14 -14
  81. package/lib/core/workspace-bindings.js.map +1 -1
  82. package/lib/core/workspace-instruction-context.d.ts +3 -2260
  83. package/lib/core/workspace-instruction-context.js +104 -2
  84. package/lib/core/workspace-instruction-context.js.map +1 -0
  85. package/lib/core/workspace-instruction-routes.d.ts +1 -1
  86. package/lib/core/workspace-instructions.d.ts +2 -68
  87. package/lib/core/workspace-instructions.js +13 -0
  88. package/lib/core/workspace-instructions.js.map +1 -1
  89. package/lib/core/workspaces.d.ts +4 -3
  90. package/lib/core/workspaces.js +17 -1
  91. package/lib/core/workspaces.js.map +1 -1
  92. package/lib/host-key-C5afhAUd.d.ts +30 -0
  93. package/lib/index-VF8bcBeX.d.ts +2293 -0
  94. package/lib/{workspace-instruction-context-DytAmaPY.js → lib-gdtU_WKT.js} +159 -102
  95. package/lib/lib-gdtU_WKT.js.map +1 -0
  96. package/lib/model-BP7Gw1yk.d.ts +104 -0
  97. package/lib/node-registry-BTnnM5-k.d.ts +112 -0
  98. package/lib/node-sync-3Z9QLz2n.d.ts +89 -0
  99. package/lib/protocol-CV7LGE44.d.ts +35 -0
  100. package/lib/revision-C9sOlDZ6.d.ts +25 -0
  101. package/lib/router/router-fs.d.ts +12 -3
  102. package/lib/router/router-fs.js.map +1 -1
  103. package/lib/session-journal-pgPqcWn9.d.ts +40 -0
  104. package/lib/session-sync-EdfwUON5.js +2177 -0
  105. package/lib/session-sync-EdfwUON5.js.map +1 -0
  106. package/lib/session-sync-g6qKZzjV.d.ts +1252 -0
  107. package/lib/ssh-transport-BcIiLxsX.d.ts +55 -0
  108. package/lib/stores-CKyEt0yk.d.ts +31 -0
  109. package/lib/workspace-bindings-CsB0TIJB.d.ts +28 -0
  110. package/lib/workspace-instructions-DirYYiku.d.ts +69 -0
  111. package/package.json +20 -1
  112. package/lib/workspace-instruction-context-DytAmaPY.js.map +0 -1
@@ -0,0 +1,660 @@
1
+ import { HybridLogicalClock, compareSyncRevision } from "./revision.js";
2
+ import { isNonNegativeSafeInteger, isRecord, syncRecordKey } from "./model.js";
3
+ import { createHash } from "node:crypto";
4
+ //#region src/core/sync/node-sync.ts
5
+ const MAX_NODE_RECORDS = 1e4;
6
+ const MAX_SECRET_BYTES = 65536;
7
+ var SyncNodeManifestError = class extends Error {
8
+ constructor(message = "invalid node sync manifest") {
9
+ super(message);
10
+ this.name = "SyncNodeManifestError";
11
+ }
12
+ };
13
+ var SyncNodeInUseError = class extends Error {
14
+ workspaces;
15
+ constructor(workspaces) {
16
+ super("node is still bound to active workspaces");
17
+ this.workspaces = workspaces;
18
+ this.name = "SyncNodeInUseError";
19
+ }
20
+ };
21
+ function canonicalJson(value) {
22
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
23
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
24
+ const object = value;
25
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
26
+ }
27
+ function nodeDigest(profile, secrets, credentialsRequired) {
28
+ const canonicalSecrets = secrets && Object.keys(secrets).length > 0 ? secrets : null;
29
+ return `sha256:${createHash("sha256").update(canonicalJson({
30
+ profile,
31
+ secrets: canonicalSecrets,
32
+ credentialsRequired
33
+ })).digest("hex")}`;
34
+ }
35
+ function tombstoneDigest(metadata) {
36
+ const origin = {
37
+ originInstanceId: metadata.originInstanceId,
38
+ originEntityId: metadata.originEntityId
39
+ };
40
+ return `sha256:${createHash("sha256").update(canonicalJson({
41
+ kind: "node",
42
+ origin,
43
+ deleted: true
44
+ })).digest("hex")}`;
45
+ }
46
+ function syncable(node) {
47
+ return (node.type === "remote-ssh" || node.type === "remote-docker") && Boolean(node.ssh);
48
+ }
49
+ function sanitizedProfile(node) {
50
+ if (!syncable(node)) throw new Error("node is outside the sync scope");
51
+ return {
52
+ name: node.name,
53
+ type: node.type,
54
+ ssh: {
55
+ host: node.ssh.host,
56
+ port: node.ssh.port,
57
+ username: node.ssh.username,
58
+ auth: { kind: node.ssh.auth.kind },
59
+ ...node.ssh.jump ? { jump: node.ssh.jump.map(({ host, port, username }) => ({
60
+ host,
61
+ ...port ? { port } : {},
62
+ ...username ? { username } : {}
63
+ })) } : {},
64
+ ...node.ssh.hostKeyFingerprint ? { hostKeyFingerprint: node.ssh.hostKeyFingerprint } : {}
65
+ },
66
+ ...node.docker ? { docker: {
67
+ ...node.docker,
68
+ ...node.docker.resources ? { resources: { ...node.docker.resources } } : {}
69
+ } } : {}
70
+ };
71
+ }
72
+ function credentialsRequired(profile, secrets) {
73
+ if (profile.ssh.auth.kind === "agent") return true;
74
+ if (profile.ssh.auth.kind === "password") return !secrets?.password;
75
+ return !secrets?.privateKey;
76
+ }
77
+ function shareableSecrets(profile, secrets) {
78
+ if (profile.ssh.auth.kind === "agent") return void 0;
79
+ if (profile.ssh.auth.kind === "password") return secrets?.password ? { password: secrets.password } : void 0;
80
+ return secrets?.privateKey ? {
81
+ privateKey: secrets.privateKey,
82
+ ...secrets.passphrase ? { passphrase: secrets.passphrase } : {}
83
+ } : void 0;
84
+ }
85
+ function text(value, label, maxLength) {
86
+ if (typeof value !== "string" || !value.trim() || value.length > maxLength || /[\u0000-\u001f\u007f]/.test(value)) throw new Error(`invalid node sync ${label}`);
87
+ return value.trim();
88
+ }
89
+ function parseSsh(value) {
90
+ if (!isRecord(value)) throw new Error("invalid node sync SSH configuration");
91
+ const allowed = /* @__PURE__ */ new Set([
92
+ "host",
93
+ "port",
94
+ "username",
95
+ "auth",
96
+ "jump",
97
+ "hostKeyFingerprint"
98
+ ]);
99
+ if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error("unsupported node sync SSH field");
100
+ if (!Number.isSafeInteger(value.port) || value.port < 1 || value.port > 65535) throw new Error("invalid node sync SSH port");
101
+ if (!isRecord(value.auth) || ![
102
+ "password",
103
+ "key",
104
+ "agent"
105
+ ].includes(String(value.auth.kind))) throw new Error("invalid node sync SSH auth method");
106
+ if (Object.keys(value.auth).some((key) => key !== "kind")) throw new Error("SSH key paths cannot be imported");
107
+ const jump = value.jump;
108
+ if (jump !== void 0 && !Array.isArray(jump)) throw new Error("invalid node sync SSH jump list");
109
+ const parsedJump = jump?.map((hop) => {
110
+ if (!isRecord(hop) || Object.keys(hop).some((key) => ![
111
+ "host",
112
+ "port",
113
+ "username"
114
+ ].includes(key))) throw new Error("invalid node sync SSH jump hop");
115
+ if (hop.port !== void 0 && (!Number.isSafeInteger(hop.port) || hop.port < 1 || hop.port > 65535)) throw new Error("invalid node sync SSH jump port");
116
+ const host = text(hop.host, "SSH jump host", 255);
117
+ const username = hop.username === void 0 ? void 0 : text(hop.username, "SSH jump username", 128);
118
+ if (/\s/.test(host) || username !== void 0 && /\s/.test(username)) throw new Error("invalid node sync SSH jump address");
119
+ return {
120
+ host,
121
+ ...hop.port === void 0 ? {} : { port: hop.port },
122
+ ...username === void 0 ? {} : { username }
123
+ };
124
+ });
125
+ const fingerprint = value.hostKeyFingerprint;
126
+ if (fingerprint !== void 0 && (typeof fingerprint !== "string" || !/^SHA256:[A-Za-z0-9+/]{43}$/.test(fingerprint))) throw new Error("invalid node sync SSH host-key fingerprint");
127
+ const host = text(value.host, "SSH host", 255);
128
+ const username = text(value.username, "SSH username", 128);
129
+ if (/\s/.test(host) || /\s/.test(username)) throw new Error("invalid node sync SSH address");
130
+ return {
131
+ host,
132
+ port: value.port,
133
+ username,
134
+ auth: { kind: value.auth.kind },
135
+ ...parsedJump ? { jump: parsedJump } : {},
136
+ ...typeof fingerprint === "string" ? { hostKeyFingerprint: fingerprint } : {}
137
+ };
138
+ }
139
+ function parseDocker(value) {
140
+ if (!isRecord(value)) throw new Error("invalid node sync Docker configuration");
141
+ const allowed = /* @__PURE__ */ new Set([
142
+ "mode",
143
+ "containerId",
144
+ "image",
145
+ "resources"
146
+ ]);
147
+ if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error("unsupported node sync Docker field");
148
+ if (value.mode !== "existing" && value.mode !== "managed") throw new Error("invalid node sync Docker mode");
149
+ const containerId = value.containerId === void 0 ? void 0 : text(value.containerId, "Docker container ID", 256);
150
+ const image = value.image === void 0 ? void 0 : text(value.image, "Docker image", 512);
151
+ let resources;
152
+ if (value.resources !== void 0) {
153
+ if (!isRecord(value.resources) || Object.keys(value.resources).some((key) => !["cpus", "memoryMB"].includes(key))) throw new Error("invalid node sync Docker resources");
154
+ const cpus = value.resources.cpus;
155
+ const memoryMB = value.resources.memoryMB;
156
+ if (cpus !== void 0 && (typeof cpus !== "number" || !Number.isFinite(cpus) || cpus <= 0)) throw new Error("invalid node sync Docker CPU limit");
157
+ if (memoryMB !== void 0 && (!Number.isSafeInteger(memoryMB) || memoryMB <= 0)) throw new Error("invalid node sync Docker memory limit");
158
+ resources = {
159
+ ...cpus === void 0 ? {} : { cpus },
160
+ ...memoryMB === void 0 ? {} : { memoryMB }
161
+ };
162
+ }
163
+ return {
164
+ mode: value.mode,
165
+ ...containerId === void 0 ? {} : { containerId },
166
+ ...image === void 0 ? {} : { image },
167
+ ...resources ? { resources } : {}
168
+ };
169
+ }
170
+ function parseProfile(value) {
171
+ if (!isRecord(value)) throw new Error("invalid node sync profile");
172
+ if (Object.keys(value).some((key) => ![
173
+ "name",
174
+ "type",
175
+ "ssh",
176
+ "docker"
177
+ ].includes(key))) throw new Error("unsupported node sync profile field");
178
+ if (value.type !== "remote-ssh" && value.type !== "remote-docker") throw new Error("local nodes cannot be imported");
179
+ const ssh = parseSsh(value.ssh);
180
+ if (value.type === "remote-ssh" && value.docker !== void 0) throw new Error("SSH node contains Docker configuration");
181
+ if (value.type === "remote-docker" && value.docker === void 0) throw new Error("remote Docker node requires Docker configuration");
182
+ return {
183
+ name: text(value.name, "name", 64),
184
+ type: value.type,
185
+ ssh,
186
+ ...value.docker === void 0 ? {} : { docker: parseDocker(value.docker) }
187
+ };
188
+ }
189
+ function parseSecrets(value) {
190
+ if (value === void 0) return void 0;
191
+ if (!isRecord(value) || Object.keys(value).some((key) => ![
192
+ "password",
193
+ "privateKey",
194
+ "passphrase"
195
+ ].includes(key))) throw new Error("invalid node sync secret payload");
196
+ const result = {};
197
+ for (const key of [
198
+ "password",
199
+ "privateKey",
200
+ "passphrase"
201
+ ]) {
202
+ const secret = value[key];
203
+ if (secret === void 0) continue;
204
+ if (typeof secret !== "string" || Buffer.byteLength(secret, "utf8") > MAX_SECRET_BYTES || secret.includes("\0")) throw new Error(`invalid node sync secret: ${key}`);
205
+ if (secret) result[key] = secret;
206
+ }
207
+ return Object.keys(result).length > 0 ? result : void 0;
208
+ }
209
+ function validateMetadata(value) {
210
+ const safeText = (input) => typeof input === "string" && input.length > 0 && input.length <= 512 && !/[\u0000-\u001f\u007f]/.test(input);
211
+ if (!isRecord(value) || value.kind !== "node" || !safeText(value.originInstanceId) || !safeText(value.originEntityId) || !safeText(value.localEntityId) || !isRecord(value.revision) || !isNonNegativeSafeInteger(value.revision.physicalTimeMs) || !isNonNegativeSafeInteger(value.revision.logicalCounter) || !safeText(value.revision.writerInstanceId) || typeof value.contentDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value.contentDigest) || value.deleted !== void 0 && typeof value.deleted !== "boolean" || value.pendingRemoval !== void 0 && typeof value.pendingRemoval !== "boolean" || value.credentialsRequired !== void 0 && typeof value.credentialsRequired !== "boolean" || value.importedVia !== void 0 && value.importedVia !== "peer" && value.importedVia !== "offline" || value.sourceName !== void 0 && !safeText(value.sourceName)) throw new Error("invalid node sync metadata");
212
+ return value;
213
+ }
214
+ function makeTombstone(metadata, revision) {
215
+ const origin = {
216
+ originInstanceId: metadata.originInstanceId,
217
+ originEntityId: metadata.originEntityId
218
+ };
219
+ return {
220
+ ...metadata,
221
+ revision,
222
+ contentDigest: tombstoneDigest(origin),
223
+ deleted: true,
224
+ pendingRemoval: false
225
+ };
226
+ }
227
+ function makePlaceholderLocalId(origin) {
228
+ return `t-${createHash("sha256").update(canonicalJson(origin)).digest("hex").slice(0, 24)}`;
229
+ }
230
+ function makeImportedLocalId(origin) {
231
+ return `s-${createHash("sha256").update(canonicalJson(origin)).digest("hex").slice(0, 32)}`;
232
+ }
233
+ var SyncNodeService = class {
234
+ registry;
235
+ bindings;
236
+ state;
237
+ getWorkspaceRegistry;
238
+ disposeWorld;
239
+ mutationTail = Promise.resolve();
240
+ constructor(registry, bindings, state, getWorkspaceRegistry, disposeWorld) {
241
+ this.registry = registry;
242
+ this.bindings = bindings;
243
+ this.state = state;
244
+ this.getWorkspaceRegistry = getWorkspaceRegistry;
245
+ this.disposeWorld = disposeWorld;
246
+ }
247
+ exportManifest() {
248
+ return this.serializeMutation(() => this.exportManifestNow());
249
+ }
250
+ async exportManifestNow() {
251
+ const instanceId = this.state.instanceId();
252
+ const initial = this.state.read();
253
+ const clock = new HybridLogicalClock(instanceId, initial.clock);
254
+ const records = new Map(Object.entries(initial.records));
255
+ const changed = /* @__PURE__ */ new Map();
256
+ const localRecords = /* @__PURE__ */ new Map();
257
+ for (const metadata of records.values()) {
258
+ if (metadata.kind !== "node") continue;
259
+ const existing = localRecords.get(metadata.localEntityId);
260
+ if (!existing || compareSyncRevision(metadata.revision, existing.revision) > 0) localRecords.set(metadata.localEntityId, metadata);
261
+ }
262
+ const entries = [];
263
+ for (const node of this.registry.list()) {
264
+ if (!syncable(node)) continue;
265
+ const profile = sanitizedProfile(node);
266
+ const secrets = shareableSecrets(profile, parseSecrets(await this.registry.getSecrets(node.id)));
267
+ const credentialsNeeded = credentialsRequired(profile, secrets);
268
+ const digest = nodeDigest(profile, secrets, credentialsNeeded);
269
+ const current = localRecords.get(node.id);
270
+ if (current?.deleted) continue;
271
+ let metadata = current;
272
+ if (!metadata || metadata.contentDigest !== digest || metadata.credentialsRequired !== credentialsNeeded) {
273
+ const revision = clock.tick();
274
+ metadata = {
275
+ ...current ?? {
276
+ kind: "node",
277
+ originInstanceId: instanceId,
278
+ originEntityId: node.id,
279
+ localEntityId: node.id
280
+ },
281
+ kind: "node",
282
+ localEntityId: node.id,
283
+ revision,
284
+ contentDigest: digest,
285
+ deleted: false,
286
+ pendingRemoval: false,
287
+ credentialsRequired: credentialsNeeded
288
+ };
289
+ const key = syncRecordKey("node", metadata);
290
+ records.set(key, metadata);
291
+ localRecords.set(node.id, metadata);
292
+ changed.set(key, metadata);
293
+ }
294
+ entries.push({
295
+ metadata,
296
+ profile,
297
+ ...secrets ? { secrets } : {}
298
+ });
299
+ }
300
+ for (const metadata of records.values()) if (metadata.kind === "node" && metadata.deleted && !entries.some((entry) => sameOrigin(entry.metadata, metadata))) entries.push({ metadata });
301
+ if (changed.size > 0) this.state.update((state) => {
302
+ mergeClock(state, clock.snapshot());
303
+ for (const [key, metadata] of changed) state.records[key] = metadata;
304
+ });
305
+ return {
306
+ protocolVersion: 1,
307
+ instanceId,
308
+ records: entries
309
+ };
310
+ }
311
+ validateManifest(value) {
312
+ if (!Array.isArray(value) || value.length > MAX_NODE_RECORDS) throw new SyncNodeManifestError();
313
+ try {
314
+ const entries = value.map(parseEntry);
315
+ const keys = entries.map((entry) => syncRecordKey("node", entry.metadata));
316
+ if (new Set(keys).size !== keys.length) throw new Error("duplicate origins");
317
+ return entries;
318
+ } catch (error) {
319
+ throw new SyncNodeManifestError(error instanceof Error ? error.message : "invalid node sync manifest");
320
+ }
321
+ }
322
+ async preflightManifest(value) {
323
+ const entries = this.validateManifest(value);
324
+ const localInstanceId = this.state.instanceId();
325
+ const state = this.state.read();
326
+ for (const entry of entries) {
327
+ const { metadata } = entry;
328
+ const current = state.records[syncRecordKey("node", metadata)];
329
+ if (current) {
330
+ if (compareSyncRevision(metadata.revision, current.revision) === 0 && (metadata.contentDigest !== current.contentDigest || Boolean(metadata.deleted) !== Boolean(current.deleted))) throw new SyncNodeManifestError("conflicting node records share the same revision");
331
+ continue;
332
+ }
333
+ if (metadata.originInstanceId !== localInstanceId || metadata.deleted) continue;
334
+ const local = this.registry.get(metadata.originEntityId);
335
+ if (!local) continue;
336
+ const profile = sanitizedProfile(local);
337
+ const secrets = shareableSecrets(profile, parseSecrets(await this.registry.getSecrets(local.id)));
338
+ if (nodeDigest(profile, secrets, credentialsRequired(profile, secrets)) !== metadata.contentDigest) throw new SyncNodeManifestError("local origin metadata is missing for a changed node");
339
+ }
340
+ }
341
+ applyManifest(value, sourceName, importedVia = "peer") {
342
+ return this.serializeMutation(() => this.applyManifestNow(value, sourceName, importedVia));
343
+ }
344
+ async applyManifestNow(value, sourceName, importedVia) {
345
+ await this.preflightManifest(value);
346
+ const entries = this.validateManifest(value);
347
+ let safeSourceName;
348
+ try {
349
+ safeSourceName = text(sourceName, "source name", 128);
350
+ } catch (error) {
351
+ throw new SyncNodeManifestError(error instanceof Error ? error.message : "invalid node sync manifest");
352
+ }
353
+ const result = {
354
+ created: 0,
355
+ updated: 0,
356
+ deleted: 0,
357
+ pendingRemoval: 0,
358
+ skipped: 0
359
+ };
360
+ const localInstanceId = this.state.instanceId();
361
+ const initial = this.state.read();
362
+ const clock = new HybridLogicalClock(localInstanceId, initial.clock);
363
+ const records = new Map(Object.entries(initial.records));
364
+ const changed = /* @__PURE__ */ new Map();
365
+ for (const entry of entries) {
366
+ const { metadata } = entry;
367
+ const key = syncRecordKey("node", metadata);
368
+ const current = records.get(key);
369
+ if (metadata.originInstanceId === localInstanceId && !current) {
370
+ const local = this.registry.get(metadata.originEntityId);
371
+ if (local && !metadata.deleted) {
372
+ const localProfile = sanitizedProfile(local);
373
+ const localSecrets = shareableSecrets(localProfile, parseSecrets(await this.registry.getSecrets(local.id)));
374
+ if (nodeDigest(localProfile, localSecrets, credentialsRequired(localProfile, localSecrets)) !== metadata.contentDigest) throw new SyncNodeManifestError("local origin metadata is missing for a changed node");
375
+ clock.observe(metadata.revision);
376
+ const localMetadata = {
377
+ ...metadata,
378
+ localEntityId: local.id,
379
+ importedVia: void 0,
380
+ sourceName: void 0
381
+ };
382
+ records.set(key, localMetadata);
383
+ changed.set(key, localMetadata);
384
+ this.state.update((state) => {
385
+ mergeClock(state, clock.snapshot());
386
+ state.records[key] = localMetadata;
387
+ });
388
+ result.skipped += 1;
389
+ continue;
390
+ }
391
+ }
392
+ if (current) {
393
+ const comparison = compareSyncRevision(metadata.revision, current.revision);
394
+ if (comparison < 0) {
395
+ result.skipped += 1;
396
+ continue;
397
+ }
398
+ if (comparison === 0) {
399
+ if (metadata.contentDigest !== current.contentDigest || Boolean(metadata.deleted) !== Boolean(current.deleted)) throw new SyncNodeManifestError("conflicting node records share the same revision");
400
+ result.skipped += 1;
401
+ continue;
402
+ }
403
+ }
404
+ clock.observe(metadata.revision);
405
+ const mappedLocalId = current?.deleted && current.localEntityId.startsWith("t-") ? void 0 : current?.localEntityId;
406
+ if (metadata.deleted) {
407
+ const localId = mappedLocalId ?? (metadata.originInstanceId === localInstanceId ? metadata.originEntityId : makePlaceholderLocalId(metadata));
408
+ const pendingMetadata = {
409
+ ...metadata,
410
+ localEntityId: localId,
411
+ importedVia: metadata.originInstanceId === localInstanceId ? void 0 : importedVia,
412
+ sourceName: metadata.originInstanceId === localInstanceId ? void 0 : safeSourceName,
413
+ pendingRemoval: true
414
+ };
415
+ this.saveRecord(pendingMetadata);
416
+ records.set(key, pendingMetadata);
417
+ changed.set(key, pendingMetadata);
418
+ if (this.hasActiveBinding(localId)) result.pendingRemoval += 1;
419
+ else {
420
+ await this.removeNodeAndStaleBindings(localId);
421
+ const tombstoneMetadata = {
422
+ ...metadata,
423
+ localEntityId: localId,
424
+ importedVia: metadata.originInstanceId === localInstanceId ? void 0 : importedVia,
425
+ sourceName: metadata.originInstanceId === localInstanceId ? void 0 : safeSourceName,
426
+ pendingRemoval: false
427
+ };
428
+ records.set(key, tombstoneMetadata);
429
+ changed.set(key, tombstoneMetadata);
430
+ this.saveRecord(tombstoneMetadata);
431
+ result.deleted += 1;
432
+ }
433
+ continue;
434
+ }
435
+ if (!entry.profile) throw new SyncNodeManifestError("node profile is missing from active manifest record");
436
+ const input = toNodeCreateInput(entry.profile, entry.secrets);
437
+ const localId = mappedLocalId ?? (metadata.originInstanceId === localInstanceId ? metadata.originEntityId : makeImportedLocalId(metadata));
438
+ let node;
439
+ if (this.registry.get(localId)) {
440
+ node = await this.registry.updateImported(localId, input);
441
+ result.updated += 1;
442
+ } else {
443
+ node = await this.registry.createImported(input, localId);
444
+ result.created += 1;
445
+ }
446
+ this.disposeWorld?.(node.id);
447
+ const appliedMetadata = {
448
+ ...metadata,
449
+ localEntityId: node.id,
450
+ importedVia: metadata.originInstanceId === localInstanceId ? void 0 : importedVia,
451
+ sourceName: metadata.originInstanceId === localInstanceId ? void 0 : safeSourceName,
452
+ deleted: false,
453
+ pendingRemoval: false,
454
+ credentialsRequired: credentialsRequired(entry.profile, entry.secrets)
455
+ };
456
+ records.set(key, appliedMetadata);
457
+ changed.set(key, appliedMetadata);
458
+ this.state.update((state) => {
459
+ mergeClock(state, clock.snapshot());
460
+ state.records[key] = appliedMetadata;
461
+ });
462
+ }
463
+ const finalClock = clock.snapshot();
464
+ if (changed.size > 0 || finalClock.physicalTimeMs !== initial.clock.physicalTimeMs || finalClock.logicalCounter !== initial.clock.logicalCounter) this.state.update((state) => {
465
+ mergeClock(state, finalClock);
466
+ for (const [key, metadata] of changed) state.records[key] = metadata;
467
+ });
468
+ return result;
469
+ }
470
+ removeLocalNode(nodeId, remove) {
471
+ return this.serializeMutation(() => this.removeLocalNodeNow(nodeId, remove));
472
+ }
473
+ async removeLocalNodeNow(nodeId, remove) {
474
+ const node = this.registry.get(nodeId);
475
+ if (!node || !syncable(node)) {
476
+ await remove();
477
+ return;
478
+ }
479
+ const active = this.activeBindings(nodeId);
480
+ if (active.length > 0) throw new SyncNodeInUseError(active.map((binding) => binding.workspaceId ?? binding.mirrorPath));
481
+ const current = findNodeRecord(Object.values(this.state.read().records), nodeId);
482
+ const instanceId = this.state.instanceId();
483
+ const tombstone = {
484
+ ...makeTombstone(current ?? {
485
+ kind: "node",
486
+ originInstanceId: instanceId,
487
+ originEntityId: nodeId,
488
+ localEntityId: nodeId,
489
+ revision: {
490
+ physicalTimeMs: 0,
491
+ logicalCounter: 0,
492
+ writerInstanceId: instanceId
493
+ },
494
+ contentDigest: ""
495
+ }, this.state.nextRevision()),
496
+ pendingRemoval: true
497
+ };
498
+ const key = syncRecordKey("node", tombstone);
499
+ this.saveRecord(tombstone);
500
+ try {
501
+ await remove();
502
+ this.saveRecord({
503
+ ...tombstone,
504
+ pendingRemoval: false
505
+ });
506
+ } catch (error) {
507
+ this.state.update((state) => {
508
+ if (current) state.records[key] = current;
509
+ else delete state.records[key];
510
+ });
511
+ throw error;
512
+ }
513
+ }
514
+ view(nodeId) {
515
+ return this.views([nodeId]).get(nodeId);
516
+ }
517
+ views(nodeIds) {
518
+ const wanted = new Set(nodeIds);
519
+ const metadataById = /* @__PURE__ */ new Map();
520
+ for (const metadata of Object.values(this.state.read().records)) {
521
+ if (metadata.kind !== "node" || !wanted.has(metadata.localEntityId)) continue;
522
+ const current = metadataById.get(metadata.localEntityId);
523
+ if (!current || compareSyncRevision(metadata.revision, current.revision) > 0) metadataById.set(metadata.localEntityId, metadata);
524
+ }
525
+ const views = /* @__PURE__ */ new Map();
526
+ for (const [localId, metadata] of metadataById) views.set(localId, {
527
+ ...metadata.importedVia ? { importedVia: metadata.importedVia } : {},
528
+ ...metadata.sourceName ? { sourceName: metadata.sourceName } : {},
529
+ pendingRemoval: Boolean(metadata.deleted && metadata.pendingRemoval),
530
+ ...metadata.credentialsRequired === void 0 ? {} : { credentialsRequired: metadata.credentialsRequired }
531
+ });
532
+ return views;
533
+ }
534
+ isPendingRemoval(nodeId) {
535
+ return this.view(nodeId)?.pendingRemoval ?? false;
536
+ }
537
+ originForLocalNode(nodeId) {
538
+ const metadata = findNodeRecord(Object.values(this.state.read().records), nodeId);
539
+ return !metadata || metadata.deleted ? void 0 : {
540
+ originInstanceId: metadata.originInstanceId,
541
+ originEntityId: metadata.originEntityId
542
+ };
543
+ }
544
+ localNodeIdForOrigin(origin) {
545
+ const metadata = Object.values(this.state.read().records).filter((record) => record.kind === "node" && record.originInstanceId === origin.originInstanceId && record.originEntityId === origin.originEntityId).sort((a, b) => compareSyncRevision(b.revision, a.revision))[0];
546
+ return !metadata || metadata.deleted ? void 0 : metadata.localEntityId;
547
+ }
548
+ withAvailableRemoteNode(origin, operation) {
549
+ return this.serializeMutation(async () => {
550
+ const nodeId = this.localNodeIdForOrigin(origin);
551
+ const node = nodeId ? this.registry.get(nodeId) : void 0;
552
+ if (!node || !syncable(node) || this.isPendingRemoval(node.id)) return void 0;
553
+ return operation(node.id);
554
+ });
555
+ }
556
+ reconcilePendingRemovals() {
557
+ return this.serializeMutation(() => this.reconcilePendingRemovalsNow());
558
+ }
559
+ async reconcilePendingRemovalsNow() {
560
+ const records = Object.values(this.state.read().records).filter((metadata) => metadata.kind === "node" && metadata.deleted && metadata.pendingRemoval);
561
+ for (const metadata of records) {
562
+ if (this.hasActiveBinding(metadata.localEntityId)) continue;
563
+ await this.removeNodeAndStaleBindings(metadata.localEntityId);
564
+ this.saveRecord({
565
+ ...metadata,
566
+ pendingRemoval: false
567
+ });
568
+ }
569
+ }
570
+ hasActiveBinding(nodeId) {
571
+ return this.activeBindings(nodeId).length > 0;
572
+ }
573
+ activeBindings(nodeId) {
574
+ const bindings = this.bindings.list().filter((binding) => binding.nodeId === nodeId);
575
+ if (bindings.length === 0) return [];
576
+ let registry;
577
+ try {
578
+ registry = this.getWorkspaceRegistry?.();
579
+ } catch {}
580
+ if (!registry) return bindings;
581
+ try {
582
+ const activePaths = new Set(registry.list().map((workspace) => workspace.path));
583
+ return bindings.filter((binding) => activePaths.has(binding.mirrorPath));
584
+ } catch {
585
+ return bindings;
586
+ }
587
+ }
588
+ async removeNodeAndStaleBindings(nodeId) {
589
+ const bindings = this.bindings.list().filter((binding) => binding.nodeId === nodeId);
590
+ let activePaths;
591
+ try {
592
+ const registry = this.getWorkspaceRegistry?.();
593
+ if (registry) activePaths = new Set(registry.list().map((workspace) => workspace.path));
594
+ } catch {}
595
+ for (const binding of bindings) {
596
+ if (activePaths?.has(binding.mirrorPath)) continue;
597
+ this.bindings.remove(binding.mirrorPath);
598
+ }
599
+ if (this.registry.get(nodeId)) {
600
+ await this.registry.removeImported(nodeId);
601
+ this.disposeWorld?.(nodeId);
602
+ } else await this.registry.removeImported(nodeId);
603
+ }
604
+ saveRecord(metadata) {
605
+ const key = syncRecordKey("node", metadata);
606
+ this.state.update((state) => {
607
+ state.records[key] = metadata;
608
+ });
609
+ }
610
+ serializeMutation(operation) {
611
+ const result = this.mutationTail.then(operation);
612
+ this.mutationTail = result.then(() => void 0, () => void 0);
613
+ return result;
614
+ }
615
+ };
616
+ function mergeClock(state, incoming) {
617
+ if (incoming.physicalTimeMs > state.clock.physicalTimeMs || incoming.physicalTimeMs === state.clock.physicalTimeMs && incoming.logicalCounter > state.clock.logicalCounter) state.clock = incoming;
618
+ }
619
+ function findNodeRecord(records, localId) {
620
+ return [...records].filter((metadata) => metadata.kind === "node" && metadata.localEntityId === localId).sort((a, b) => compareSyncRevision(b.revision, a.revision))[0];
621
+ }
622
+ function sameOrigin(a, b) {
623
+ return a.kind === b.kind && a.originInstanceId === b.originInstanceId && a.originEntityId === b.originEntityId;
624
+ }
625
+ function toNodeCreateInput(profile, secrets) {
626
+ return {
627
+ name: profile.name,
628
+ type: profile.type,
629
+ ssh: profile.ssh,
630
+ ...profile.docker ? { docker: profile.docker } : {},
631
+ ...secrets ? { secrets } : {}
632
+ };
633
+ }
634
+ function parseEntry(value) {
635
+ if (!isRecord(value)) throw new Error("invalid node sync entry");
636
+ const metadata = validateMetadata(value.metadata);
637
+ if (metadata.deleted) {
638
+ if (value.profile !== void 0 || value.secrets !== void 0 || metadata.contentDigest !== tombstoneDigest(metadata)) throw new Error("invalid node sync tombstone");
639
+ return { metadata };
640
+ }
641
+ const profile = parseProfile(value.profile);
642
+ const rawSecrets = parseSecrets(value.secrets);
643
+ if (profile.ssh.auth.kind === "agent" && rawSecrets || profile.ssh.auth.kind === "password" && (rawSecrets?.privateKey || rawSecrets?.passphrase) || profile.ssh.auth.kind === "key" && (rawSecrets?.password || !rawSecrets?.privateKey && rawSecrets?.passphrase)) throw new Error("node sync secrets do not match the SSH auth method");
644
+ const secrets = shareableSecrets(profile, rawSecrets);
645
+ const needed = credentialsRequired(profile, secrets);
646
+ if (metadata.credentialsRequired !== void 0 && metadata.credentialsRequired !== needed) throw new Error("node sync credential status does not match its payload");
647
+ if (metadata.contentDigest !== nodeDigest(profile, secrets, needed)) throw new Error("node sync digest mismatch");
648
+ return {
649
+ metadata: {
650
+ ...metadata,
651
+ credentialsRequired: needed
652
+ },
653
+ profile,
654
+ ...secrets ? { secrets } : {}
655
+ };
656
+ }
657
+ //#endregion
658
+ export { SyncNodeInUseError, SyncNodeManifestError, SyncNodeService };
659
+
660
+ //# sourceMappingURL=node-sync.js.map