@tiphareth/dsh-hardssh 0.1.2 → 0.2.3

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 (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +77 -50
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +333 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +42 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +59 -26
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +171 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -75,6 +75,10 @@ export interface WorkspaceSearchHit {
75
75
  /** Path relative to the search root. */
76
76
  rel: string
77
77
  isDir: boolean
78
+ /** Optional raw matched line (`path:line:content`, engine-style) for a
79
+ * content grep when the backend can report snippets; absent when the
80
+ * search only returns matching paths. */
81
+ match?: string
78
82
  }
79
83
 
80
84
  /** Filename / content search capability. */
@@ -1,107 +1,156 @@
1
- /**
2
- * Ledger-backed workspace router — the concrete `WorkspaceRouter` that ties
3
- * the ledger's anchor index, the namespace codec, and the provider registry
4
- * together. Any provider can be routed through this router with zero SSH
5
- * knowledge: a session cwd inside a workspace's anchor resolves to that
6
- * workspace's provider connection; an explicit `wfs://…` (or legacy `ssh:…`)
7
- * target key resolves to the owning connection.
8
- *
9
- * The router opens connections lazily and caches them per workspace id;
10
- * removed workspaces drop their connections so stale keys fail closed.
11
- *
12
- * @module @tiphareth/dsh-hardssh/base/ledger-router
13
- */
14
-
15
- import type { WorkspaceConnection, WorkspaceRecord } from './model.ts'
1
+ /** Ledger-backed routing, readiness, single-flight opening, and connection ownership. */
2
+
3
+ import type { LedgerChange, WorkspaceLedger } from './ledger.ts'
4
+ import { isPathUnderAnchor, normalizeAnchorPath } from './ledger.ts'
5
+ import type { WorkspaceConnection, WorkspaceId, WorkspaceRecord } from './model.ts'
16
6
  import { defaultNamespaceCodec, type WorkspaceNamespaceCodec, type WorkspaceRoute } from './namespace.ts'
17
- import type { WorkspaceRegistry } from './registry.ts'
18
- import { isPathUnderAnchor, normalizeAnchorPath, type WorkspaceLedger } from './ledger.ts'
7
+ import type { ProviderChange, WorkspaceProviderRegistry } from './registry.ts'
19
8
  import type { WorkspaceResolution, WorkspaceRouter } from './router.ts'
20
9
 
21
- /** Open-or-reuse strategy for workspace connections. */
10
+ /** Router configuration that remains independent of any concrete provider. */
22
11
  export interface LedgerRouterOptions {
23
12
  codec?: WorkspaceNamespaceCodec
24
- /** Called with the record; resolves to a connection (undefined = cannot open). */
25
- open(record: WorkspaceRecord, signal?: AbortSignal): Promise<WorkspaceConnection | undefined>
26
- /** Called when a workspace is removed (drop cache / pool leases). */
27
- onRemoved?(workspaceId: string): void
13
+ logger?: Pick<Console, 'warn' | 'error' | 'info' | 'debug'>
28
14
  }
29
15
 
30
- /** Ledger + registry based router. */
16
+ /** Ledger + provider-registry based router. */
31
17
  export class LedgerWorkspaceRouter implements WorkspaceRouter {
32
18
  readonly codec: WorkspaceNamespaceCodec
33
- private readonly connections = new Map<string, WorkspaceConnection>()
19
+ private readonly connections = new Map<WorkspaceId, WorkspaceConnection>()
20
+ private readonly connectionBindings = new Map<WorkspaceId, string>()
21
+ private readonly opening = new Map<WorkspaceId, Promise<WorkspaceConnection | undefined>>()
22
+ private readonly closing = new Set<Promise<void>>()
34
23
  private readonly anchors: Array<{ anchor: string; record: WorkspaceRecord }> = []
24
+ private readonly lifecycleAbort = new AbortController()
25
+ private initializePromise: Promise<void> | undefined
26
+ private ready = false
27
+ private closed = false
28
+ private ledgerDisposer: (() => void) | undefined
29
+ private providerDisposer: (() => void) | undefined
35
30
 
36
31
  constructor(
37
32
  private readonly ledger: WorkspaceLedger,
38
- private readonly registry: WorkspaceRegistry,
39
- private readonly options: LedgerRouterOptions,
33
+ private readonly providers: WorkspaceProviderRegistry,
34
+ private readonly options: LedgerRouterOptions = {},
40
35
  ) {
41
36
  this.codec = options.codec ?? defaultNamespaceCodec
37
+ }
38
+
39
+ /** ensureOpen() warms one supplied record only, so initialize loads, subscribes, and preopens the complete persisted ledger before readiness. */
40
+ initialize(): Promise<void> {
41
+ if (this.initializePromise !== undefined) return this.initializePromise
42
+ if (this.closed) return Promise.reject(new Error('LedgerWorkspaceRouter is closed'))
43
+ this.initializePromise = this.initializeInternal()
44
+ return this.initializePromise
45
+ }
46
+
47
+ /** initialize() is asynchronous, so isReady provides the synchronous seam gate it cannot provide. */
48
+ isReady(): boolean {
49
+ return this.ready && !this.closed
50
+ }
51
+
52
+ /** isReady() only reports a snapshot, so whenReady lets asynchronous consumers await the initialization outcome. */
53
+ async whenReady(): Promise<void> {
54
+ await this.initialize()
55
+ }
56
+
57
+ /** ensureOpen() requires a record, so openById performs fail-closed ledger identity lookup before single-flight opening. */
58
+ async openById(id: WorkspaceId, signal?: AbortSignal): Promise<WorkspaceConnection | undefined> {
59
+ if (this.closed) return undefined
60
+ if (signal?.aborted === true) throw abortError()
61
+ const record = await this.ledger.get(id)
62
+ if (record === undefined) return undefined
63
+ return awaitWithSignal(this.openFor(record), signal)
64
+ }
65
+
66
+ /** openById() cannot translate a host anchor, so openByAnchor resolves ledger ownership before opening. */
67
+ async openByAnchor(path: string, signal?: AbortSignal): Promise<WorkspaceConnection | undefined> {
68
+ if (this.closed) return undefined
69
+ const record = await this.ledger.findByAnchor(path)
70
+ if (record === undefined) return undefined
71
+ return this.openById(record.id, signal)
72
+ }
73
+
74
+ /** initialize() owns promise reuse, so initializeInternal performs the one actual load/subscribe/preopen sequence. */
75
+ private async initializeInternal(): Promise<void> {
76
+ const records = await this.ledger.load()
77
+ if (this.closed) return
42
78
  this.reindex()
43
- // Keep the anchor index fresh on every ledger commit.
44
- this.ledger.subscribe(() => this.reindex())
79
+ this.ledgerDisposer = this.ledger.subscribe(change => this.handleLedgerChange(change))
80
+ this.providerDisposer = this.providers.subscribe(change => this.handleProviderChange(change))
81
+ await Promise.all(records.map(async (record) => {
82
+ if (!this.providers.has(record.provider.id)) return
83
+ await this.openFor(record)
84
+ }))
85
+ if (!this.closed) this.ready = true
45
86
  }
46
87
 
47
88
  private reindex(): void {
48
89
  const snapshot = this.ledger.snapshotSync()
49
- const seen = new Set<string>()
90
+ const recordsById = new Map(snapshot.records.map(record => [record.id, record]))
50
91
  const anchors: Array<{ anchor: string; record: WorkspaceRecord }> = []
51
92
  for (const record of snapshot.records) {
52
- seen.add(record.id.toLowerCase())
53
- if (record.anchor !== undefined) {
54
- anchors.push({ anchor: normalizeAnchorPath(record.anchor.path), record })
55
- }
93
+ if (record.anchor !== undefined) anchors.push({ anchor: normalizeAnchorPath(record.anchor.path), record })
56
94
  }
57
95
  anchors.sort((a, b) => b.anchor.length - a.anchor.length)
58
96
  this.anchors.length = 0
59
97
  this.anchors.push(...anchors)
60
- // Drop connections of removed workspaces (fail-closed on stale keys).
61
- for (const id of [...this.connections.keys()]) {
62
- if (!seen.has(id.toLowerCase())) {
63
- const connection = this.connections.get(id)
64
- this.connections.delete(id)
65
- this.options.onRemoved?.(id)
66
- void connection?.close().catch(() => undefined)
67
- }
98
+ for (const [id] of this.connections) {
99
+ const record = recordsById.get(id)
100
+ if (record === undefined || this.connectionBindings.get(id) !== bindingKey(record)) this.detach(id)
68
101
  }
69
102
  }
70
103
 
71
- private async openFor(record: WorkspaceRecord, signal?: AbortSignal): Promise<WorkspaceConnection | undefined> {
72
- const id = record.id
73
- const existing = this.connections.get(id)
74
- if (existing !== undefined) return existing
75
- const opened = await this.options.open(record, signal)
76
- if (opened !== undefined) this.connections.set(id, opened)
77
- return opened
104
+ private async openFor(record: WorkspaceRecord): Promise<WorkspaceConnection | undefined> {
105
+ const existing = this.connections.get(record.id)
106
+ if (existing !== undefined && this.connectionBindings.get(record.id) === bindingKey(record)) return existing
107
+ const inFlight = this.opening.get(record.id)
108
+ if (inFlight !== undefined) return inFlight
109
+ const expectedBinding = bindingKey(record)
110
+ const provider = this.providers.get(record.provider.id)
111
+ if (provider === undefined) return undefined
112
+ const opening = (async () => {
113
+ await provider.validate(record)
114
+ const opened = await provider.open(record, { signal: this.lifecycleAbort.signal, logger: this.options.logger })
115
+ const current = await this.ledger.get(record.id)
116
+ if (this.closed
117
+ || current === undefined
118
+ || bindingKey(current) !== expectedBinding
119
+ || this.providers.get(record.provider.id) !== provider) {
120
+ await this.closeConnection(opened)
121
+ return undefined
122
+ }
123
+ const replaced = this.connections.get(record.id)
124
+ if (replaced !== undefined && replaced !== opened) await this.closeConnection(replaced)
125
+ this.connections.set(record.id, opened)
126
+ this.connectionBindings.set(record.id, expectedBinding)
127
+ return opened
128
+ })()
129
+ this.opening.set(record.id, opening)
130
+ void opening.finally(() => {
131
+ if (this.opening.get(record.id) === opening) this.opening.delete(record.id)
132
+ }).catch(() => undefined)
133
+ return opening
78
134
  }
79
135
 
80
136
  fromNamespace(key: string): WorkspaceResolution | undefined {
137
+ if (!this.isReady()) return undefined
81
138
  const route = this.codec.decode(key)
82
139
  if (route === undefined) return undefined
83
140
  const resolution = this.resolveRoute(route)
84
- if (resolution === undefined) return undefined
85
- // Preserve the raw path as the raw key.
86
- return { ...resolution, rawKey: route.path }
141
+ return resolution === undefined ? undefined : { ...resolution, rawKey: route.path }
87
142
  }
88
143
 
89
144
  fromAnchor(cwd: string | undefined): WorkspaceConnection | undefined {
90
- if (cwd === undefined || cwd === '') return undefined
145
+ if (!this.isReady() || cwd === undefined || cwd === '') return undefined
91
146
  for (const { anchor, record } of this.anchors) {
92
- if (isPathUnderAnchor(anchor, cwd)) {
93
- // Synchronous best-effort: return a cached connection; opening is async.
94
- const existing = this.connections.get(record.id)
95
- if (existing !== undefined) return existing
96
- // Keep routing crisp: the runtime pre-opens active records; a miss
97
- // here simply means the DSH adapter awaits openFor at mount time.
98
- return undefined
99
- }
147
+ if (isPathUnderAnchor(anchor, cwd)) return this.connections.get(record.id)
100
148
  }
101
149
  return undefined
102
150
  }
103
151
 
104
152
  resolveRoute(route: WorkspaceRoute): WorkspaceResolution | undefined {
153
+ if (!this.isReady()) return undefined
105
154
  const record = this.ledger.snapshotSync().records.find(candidate => candidate.id === route.workspaceId)
106
155
  if (record === undefined) return undefined
107
156
  const connection = this.connections.get(record.id)
@@ -109,9 +158,10 @@ export class LedgerWorkspaceRouter implements WorkspaceRouter {
109
158
  return { connection, rawKey: route.path }
110
159
  }
111
160
 
112
- /** Async open used by the adapter at mount time (pre-warm active records). */
161
+ /** Async compatibility entry used by older adapters that already hold a detached record. */
113
162
  async ensureOpen(record: WorkspaceRecord, signal?: AbortSignal): Promise<WorkspaceConnection | undefined> {
114
- return this.openFor(record, signal)
163
+ if (this.closed) return undefined
164
+ return awaitWithSignal(this.openFor(record), signal)
115
165
  }
116
166
 
117
167
  /** All currently cached connections (for lifecycle/teardown). */
@@ -119,10 +169,109 @@ export class LedgerWorkspaceRouter implements WorkspaceRouter {
119
169
  return [...this.connections.values()]
120
170
  }
121
171
 
122
- /** Close every cached connection (plugin teardown). */
172
+ /** Close every cached and in-flight connection and release both subscriptions. */
123
173
  async closeAll(): Promise<void> {
174
+ if (this.closed && this.opening.size === 0 && this.closing.size === 0 && this.connections.size === 0) return
175
+ this.closed = true
176
+ this.ready = false
177
+ this.lifecycleAbort.abort()
178
+ this.ledgerDisposer?.()
179
+ this.providerDisposer?.()
180
+ this.ledgerDisposer = undefined
181
+ this.providerDisposer = undefined
182
+ const openings = [...this.opening.values()]
124
183
  const connections = [...this.connections.values()]
125
184
  this.connections.clear()
126
- await Promise.all(connections.map(connection => connection.close().catch(() => undefined)))
185
+ this.connectionBindings.clear()
186
+ await Promise.allSettled(openings)
187
+ await Promise.allSettled(connections.map(connection => this.closeConnection(connection)))
188
+ await Promise.allSettled([...this.closing])
189
+ this.opening.clear()
190
+ }
191
+
192
+ /** reindex() only updates synchronous ownership, so this handler also closes and reopens resources after ledger commits. */
193
+ private handleLedgerChange(change: LedgerChange): void {
194
+ const previousBindings = new Map(this.connectionBindings)
195
+ this.reindex()
196
+ if (change.type === 'removed') {
197
+ this.detach(change.record.id)
198
+ return
199
+ }
200
+ if (change.type === 'updated' || change.type === 'renamed') {
201
+ if (previousBindings.get(change.record.id) !== bindingKey(change.record)) this.reopen(change.record.id)
202
+ return
203
+ }
204
+ if (change.type === 'created') {
205
+ if (this.ready) this.reopen(change.record.id)
206
+ return
207
+ }
208
+ for (const record of change.records) {
209
+ if (this.ready && this.providers.has(record.provider.id) && !this.connections.has(record.id)) this.reopen(record.id)
210
+ }
211
+ }
212
+
213
+ /** openFor() cannot observe provider lifecycle events itself, so this handler invalidates unregisters and preopens later registrations. */
214
+ private handleProviderChange(change: ProviderChange): void {
215
+ if (change.type === 'unregistered') {
216
+ for (const record of this.ledger.snapshotSync().records) {
217
+ if (record.provider.id === change.provider.manifest.id) this.detach(record.id)
218
+ }
219
+ return
220
+ }
221
+ if (!this.ready) return
222
+ for (const record of this.ledger.snapshotSync().records) {
223
+ if (record.provider.id === change.provider.manifest.id) this.reopen(record.id)
224
+ }
225
+ }
226
+
227
+ /** openFor() reuses an in-flight promise, so reopen waits for stale work before retrying the current record. */
228
+ private reopen(id: WorkspaceId): void {
229
+ const pending = this.opening.get(id)
230
+ this.detach(id)
231
+ void (async () => {
232
+ if (pending !== undefined) await pending.catch(() => undefined)
233
+ if (!this.closed) await this.openById(id).catch(error => this.options.logger?.warn?.('workspace reopen failed', error))
234
+ })()
127
235
  }
128
- }
236
+
237
+ /** reindex() cannot await resource release, so detach removes routing synchronously and tracks close completion separately. */
238
+ private detach(id: WorkspaceId): void {
239
+ const connection = this.connections.get(id)
240
+ this.connections.delete(id)
241
+ this.connectionBindings.delete(id)
242
+ if (connection !== undefined) void this.closeConnection(connection)
243
+ }
244
+
245
+ /** WorkspaceConnection.close() alone is not tracked, so closeConnection lets closeAll await invalidation work already in progress. */
246
+ private closeConnection(connection: WorkspaceConnection): Promise<void> {
247
+ const closing = connection.close().catch(error => {
248
+ this.options.logger?.warn?.('workspace connection close failed', error)
249
+ })
250
+ this.closing.add(closing)
251
+ void closing.finally(() => this.closing.delete(closing))
252
+ return closing
253
+ }
254
+ }
255
+
256
+ /** WorkspaceRecord.updatedAt changes on every edit, so router invalidation needs a stable binding-only key instead. */
257
+ function bindingKey(record: WorkspaceRecord): string {
258
+ return JSON.stringify({ provider: record.provider, location: record.location })
259
+ }
260
+
261
+ /** openFor() is shared by callers, so a caller signal must cancel only its wait rather than the shared provider open. */
262
+ function awaitWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
263
+ if (signal === undefined) return promise
264
+ if (signal.aborted) return Promise.reject(abortError())
265
+ return new Promise<T>((resolve, reject) => {
266
+ const onAbort = () => reject(abortError())
267
+ signal.addEventListener('abort', onAbort, { once: true })
268
+ void promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
269
+ })
270
+ }
271
+
272
+ /** awaitWithSignal() needs a consistent AbortError, which Error() alone cannot identify by name. */
273
+ function abortError(): Error {
274
+ const error = new Error('Workspace open aborted')
275
+ error.name = 'AbortError'
276
+ return error
277
+ }