@tiphareth/dsh-hardssh 0.1.2 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +58 -25
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/base/capability.ts
CHANGED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
10
|
+
/** Router configuration that remains independent of any concrete provider. */
|
|
22
11
|
export interface LedgerRouterOptions {
|
|
23
12
|
codec?: WorkspaceNamespaceCodec
|
|
24
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
44
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
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
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|