pipeline-moe 0.1.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.
- package/.env.example +26 -0
- package/LICENSE +21 -0
- package/README.md +408 -0
- package/bin/pipeline-moe.mjs +40 -0
- package/package.json +68 -0
- package/presets/2106BUILD.json +163 -0
- package/presets/CHEAPBUILD.json +97 -0
- package/presets/FREEROOM.json +96 -0
- package/presets/Versa.json +145 -0
- package/presets/cloud-main.json +76 -0
- package/presets/cloud-sprint.json +70 -0
- package/presets/local-default.json +152 -0
- package/presets/main.json +147 -0
- package/presets/mainmix.json +145 -0
- package/src/circuit-breaker.ts +141 -0
- package/src/config.ts +46 -0
- package/src/custom-tools/arxiv-search.ts +186 -0
- package/src/custom-tools/check-room.ts +45 -0
- package/src/custom-tools/destroy-room.ts +45 -0
- package/src/custom-tools/index.ts +75 -0
- package/src/custom-tools/spawn-room.ts +99 -0
- package/src/custom-tools/stop-room.ts +50 -0
- package/src/custom-tools/web-read.ts +104 -0
- package/src/custom-tools/web-search.ts +144 -0
- package/src/custom-tools/youcom-search.ts +230 -0
- package/src/custom-tools/youtube-transcript.ts +124 -0
- package/src/local-model-lock.ts +52 -0
- package/src/model.ts +131 -0
- package/src/orchestrator.ts +59 -0
- package/src/participant.ts +423 -0
- package/src/path-guard.ts +13 -0
- package/src/personas.ts +480 -0
- package/src/receipts.ts +120 -0
- package/src/registry.ts +317 -0
- package/src/room-manager.ts +505 -0
- package/src/room.ts +1657 -0
- package/src/sandbox-tools.ts +141 -0
- package/src/server.ts +1846 -0
- package/src/sse.ts +86 -0
- package/src/sshfs.ts +139 -0
- package/src/store.ts +79 -0
- package/src/types.ts +131 -0
- package/src/validation.ts +36 -0
- package/tsconfig.json +15 -0
- package/web/dist/assets/index-CmMGhMKG.css +1 -0
- package/web/dist/assets/index-LAGqbZII.js +41 -0
- package/web/dist/index.html +13 -0
- package/web/tsconfig.json +21 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
// RoomManager — thin coordinator that holds multiple Room instances.
|
|
2
|
+
//
|
|
3
|
+
// In the current (single-room) mode, `createDefaultRoom()` creates one "default"
|
|
4
|
+
// Room and all routes talk to it. Future multi-room support adds createRoom() /
|
|
5
|
+
// destroyRoom() and routes keyed by roomId.
|
|
6
|
+
|
|
7
|
+
import { resolve } from "node:path"
|
|
8
|
+
import { readFile, writeFile, rename, mkdir, readdir } from "node:fs/promises"
|
|
9
|
+
import type { Dirent } from "node:fs"
|
|
10
|
+
import { Registry } from "./registry.js"
|
|
11
|
+
import { Room } from "./room.js"
|
|
12
|
+
import { SseHub } from "./sse.js"
|
|
13
|
+
import { ConversationStore } from "./store.js"
|
|
14
|
+
import { config } from "./config.js"
|
|
15
|
+
import { LocalModelLock } from "./local-model-lock.js"
|
|
16
|
+
import { mountSshfs, unmountSshfs } from "./sshfs.js"
|
|
17
|
+
import type { RoomMount } from "./sshfs.js"
|
|
18
|
+
import type { ResolvedModel } from "./model.js"
|
|
19
|
+
import type { RoomOrchestrator } from "./orchestrator.js"
|
|
20
|
+
import type { Persona } from "./types.js"
|
|
21
|
+
|
|
22
|
+
export interface RoomSummary {
|
|
23
|
+
roomId: string
|
|
24
|
+
name: string
|
|
25
|
+
participantCount: number
|
|
26
|
+
goalStatus: string
|
|
27
|
+
goalText: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RoomDetails extends RoomSummary {
|
|
31
|
+
isBusy: boolean
|
|
32
|
+
transcriptLength: number
|
|
33
|
+
/** The directory this room's agents are scoped to. */
|
|
34
|
+
workspaceDir: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One room's durable record in the manifest. The mountpoint is deliberately
|
|
38
|
+
* NOT stored — it is ephemeral (/tmp). For sshfs rooms we store the original
|
|
39
|
+
* `sshTarget` and re-mount on restart; for custom local-path rooms we store
|
|
40
|
+
* `workspaceDir`; for default-scoped rooms neither is present. */
|
|
41
|
+
export interface RoomManifestEntry {
|
|
42
|
+
roomId: string
|
|
43
|
+
name: string
|
|
44
|
+
workspaceDir?: string
|
|
45
|
+
sshTarget?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Durable per-room metadata, persisted to `sessions/<roomId>/meta.json`. Unlike
|
|
49
|
+
* the manifest entry, it is NOT removed on destroyRoom — it outlives the room so
|
|
50
|
+
* a destroyed/closed room's conversation data can be resumed later. */
|
|
51
|
+
export interface RoomMeta {
|
|
52
|
+
roomId: string
|
|
53
|
+
name: string
|
|
54
|
+
/** Durable scope INPUT (local path or `user@host:/path`). Absent = default workspace. */
|
|
55
|
+
workspaceDir?: string
|
|
56
|
+
createdAt: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A room that has on-disk data but is not currently live — a resume candidate. */
|
|
60
|
+
export interface ResumableRoom {
|
|
61
|
+
roomId: string
|
|
62
|
+
name: string
|
|
63
|
+
workspaceDir?: string
|
|
64
|
+
/** Latest conversation's updatedAt (else meta.createdAt, else 0). */
|
|
65
|
+
lastActivity: number
|
|
66
|
+
messageCount: number
|
|
67
|
+
/** false = legacy orphan (no meta.json); name derived from a conversation title. */
|
|
68
|
+
hasMeta: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class RoomManager {
|
|
72
|
+
private rooms = new Map<
|
|
73
|
+
string,
|
|
74
|
+
{
|
|
75
|
+
room: Room
|
|
76
|
+
name: string
|
|
77
|
+
workspaceDir: string
|
|
78
|
+
/** Live FUSE mount, present only while the room's sshfs target is mounted. */
|
|
79
|
+
mount?: RoomMount
|
|
80
|
+
/** The room's *intended* sshfs target. Survives independently of `mount`:
|
|
81
|
+
* a degraded restore (mount failed, VPS down) has no live mount but must
|
|
82
|
+
* still persist this so the target isn't lost on the next save. */
|
|
83
|
+
sshTarget?: string
|
|
84
|
+
/** Creation timestamp, persisted into meta.json and stable across renames. */
|
|
85
|
+
createdAt: number
|
|
86
|
+
}
|
|
87
|
+
>()
|
|
88
|
+
/** Process-global semaphore for serializing local-model inference across all rooms. */
|
|
89
|
+
private readonly localLock = new LocalModelLock()
|
|
90
|
+
/** Capability surface for sub-room spawning, injected by the server after
|
|
91
|
+
* construction (it closes over preset/mount logic that lives in server.ts).
|
|
92
|
+
* Passed to each room's Registry so orchestrator personas get the tools.
|
|
93
|
+
* Must be set before createRoom() is called for the tools to be available. */
|
|
94
|
+
private orchestrator?: RoomOrchestrator
|
|
95
|
+
/** Serializes manifest writes so concurrent room mutations never interleave
|
|
96
|
+
* two writeFile-to-tmp operations. Each write snapshots the current Map. */
|
|
97
|
+
private saveQueue: Promise<void> = Promise.resolve()
|
|
98
|
+
|
|
99
|
+
constructor(
|
|
100
|
+
private readonly resolved: ResolvedModel,
|
|
101
|
+
private readonly hub: SseHub,
|
|
102
|
+
private readonly explicitlyEnabledProviders: Set<string>,
|
|
103
|
+
private readonly seedPersonas: Persona[],
|
|
104
|
+
) {}
|
|
105
|
+
|
|
106
|
+
/** Inject the sub-room orchestrator. Call once at startup, before createRoom. */
|
|
107
|
+
setOrchestrator(orchestrator: RoomOrchestrator): void {
|
|
108
|
+
this.orchestrator = orchestrator
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create the default room. Called once at startup in server.ts.
|
|
113
|
+
* Returns the Room so the caller can call room.init() and seed presets.
|
|
114
|
+
*/
|
|
115
|
+
createDefaultRoom(): Room {
|
|
116
|
+
return this.createRoom("default", "main-room")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a named room. Each room gets its own Registry and ConversationStore.
|
|
121
|
+
* Shared: SseHub (events are tagged with roomId), ResolvedModel, seed personas.
|
|
122
|
+
*/
|
|
123
|
+
/**
|
|
124
|
+
* Create a named room. Each room gets its own Registry and ConversationStore.
|
|
125
|
+
* @param overridePersonas When provided, replaces seedPersonas entirely (for preset-based rooms).
|
|
126
|
+
* When absent, seedPersonas are used as-is.
|
|
127
|
+
*/
|
|
128
|
+
createRoom(
|
|
129
|
+
roomId: string,
|
|
130
|
+
name: string,
|
|
131
|
+
overridePersonas?: Persona[],
|
|
132
|
+
workspaceDir?: string,
|
|
133
|
+
/** Mount metadata when the room is scoped to an sshfs target. The caller
|
|
134
|
+
* (POST handler) performs the mount and passes the local mountpoint as
|
|
135
|
+
* `workspaceDir`; this records it so destroyRoom() can unmount. */
|
|
136
|
+
mount?: RoomMount,
|
|
137
|
+
/** The intended sshfs target, used when there is no live `mount` (degraded
|
|
138
|
+
* restore). When `mount` is present its sshTarget takes precedence — they
|
|
139
|
+
* are the same value in the happy path. */
|
|
140
|
+
sshTarget?: string,
|
|
141
|
+
): Room {
|
|
142
|
+
if (this.rooms.has(roomId)) {
|
|
143
|
+
throw new Error(`Room "${roomId}" already exists`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Resolve the room's scope to an absolute path. Empty/undefined = the
|
|
147
|
+
// pipeline workspace (default, backward-compatible behavior). For sshfs
|
|
148
|
+
// rooms the caller already passes the absolute local mountpoint.
|
|
149
|
+
const scope = workspaceDir && workspaceDir.trim()
|
|
150
|
+
? resolve(workspaceDir.trim())
|
|
151
|
+
: config.workspaceDir
|
|
152
|
+
|
|
153
|
+
const registry = new Registry(this.resolved, this.hub, this.explicitlyEnabledProviders, scope, roomId, this.orchestrator)
|
|
154
|
+
const store = new ConversationStore(resolve(config.sessionsDir, roomId))
|
|
155
|
+
const personas = overridePersonas ?? this.seedPersonas
|
|
156
|
+
const room = new Room(
|
|
157
|
+
registry,
|
|
158
|
+
this.hub,
|
|
159
|
+
store,
|
|
160
|
+
personas,
|
|
161
|
+
roomId,
|
|
162
|
+
this.localLock,
|
|
163
|
+
config.circuitBreaker,
|
|
164
|
+
scope,
|
|
165
|
+
!!(mount || sshTarget), // remote (sshfs) → skip per-turn full-tree snapshots
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
this.rooms.set(roomId, {
|
|
169
|
+
room,
|
|
170
|
+
name,
|
|
171
|
+
workspaceDir: scope,
|
|
172
|
+
mount,
|
|
173
|
+
sshTarget: mount?.sshTarget ?? sshTarget,
|
|
174
|
+
createdAt: Date.now(),
|
|
175
|
+
})
|
|
176
|
+
void this.saveManifest()
|
|
177
|
+
void this.saveRoomMeta(roomId)
|
|
178
|
+
return room
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Get a room by id. Returns undefined when not found. */
|
|
182
|
+
getRoom(roomId: string): Room | undefined {
|
|
183
|
+
return this.rooms.get(roomId)?.room
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Destroy a room and remove it from the manager. Unmounts the room's sshfs
|
|
187
|
+
* mount first when present — teardown is intrinsic to the room lifecycle, so
|
|
188
|
+
* it lives here rather than in the HTTP handler. */
|
|
189
|
+
async destroyRoom(roomId: string): Promise<boolean> {
|
|
190
|
+
const entry = this.rooms.get(roomId)
|
|
191
|
+
if (!entry) return false
|
|
192
|
+
// Stop any in-flight pipeline FIRST. Deleting the room from the Map only
|
|
193
|
+
// drops our handle — the Room's async chain keeps running headless (a
|
|
194
|
+
// "zombie"): its agents continue inference (holding the process-global
|
|
195
|
+
// LocalModelLock and starving every other room) and writing into a workspace
|
|
196
|
+
// we are about to unmount. abortCurrent() flips the abort flag, cancels any
|
|
197
|
+
// running goal, and awaits the in-flight agents so the unmount below is safe.
|
|
198
|
+
await entry.room.abortCurrent()
|
|
199
|
+
if (entry.mount) {
|
|
200
|
+
await unmountSshfs(entry.mount.mountpoint)
|
|
201
|
+
}
|
|
202
|
+
const removed = this.rooms.delete(roomId)
|
|
203
|
+
if (removed) void this.saveManifest()
|
|
204
|
+
return removed
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Unmount every active sshfs mount. Called on process exit (SIGINT/SIGTERM)
|
|
208
|
+
* so a normal shutdown doesn't leave orphaned FUSE mounts behind. */
|
|
209
|
+
async cleanupAllMounts(): Promise<void> {
|
|
210
|
+
await Promise.all(
|
|
211
|
+
[...this.rooms.values()]
|
|
212
|
+
.filter((e) => e.mount)
|
|
213
|
+
.map((e) => unmountSshfs(e.mount!.mountpoint)),
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Rename a room in place. Returns false when the room is not found. */
|
|
218
|
+
renameRoom(roomId: string, newName: string): boolean {
|
|
219
|
+
const entry = this.rooms.get(roomId)
|
|
220
|
+
if (!entry) return false
|
|
221
|
+
entry.name = newName
|
|
222
|
+
void this.saveManifest()
|
|
223
|
+
void this.saveRoomMeta(roomId)
|
|
224
|
+
return true
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Room metadata (resume support) ──────────────────────────────────────
|
|
228
|
+
// A small meta.json per room session dir records the durable display name +
|
|
229
|
+
// scope. It is deliberately NOT deleted on destroyRoom — it outlives the room
|
|
230
|
+
// so listResumableRooms() / the resume route can re-open a closed room with its
|
|
231
|
+
// original name and workspace scope intact.
|
|
232
|
+
|
|
233
|
+
private roomMetaPath(roomId: string): string {
|
|
234
|
+
return resolve(config.sessionsDir, roomId, "meta.json")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The durable scope INPUT for a room (local path or `user@host:/path`),
|
|
238
|
+
* mirroring manifestEntries: sshTarget wins; a non-default local path is
|
|
239
|
+
* stored as-is; the default workspace stores nothing. */
|
|
240
|
+
private durableScope(e: { workspaceDir: string; sshTarget?: string }): string | undefined {
|
|
241
|
+
if (e.sshTarget) return e.sshTarget
|
|
242
|
+
if (e.workspaceDir !== config.workspaceDir) return e.workspaceDir
|
|
243
|
+
return undefined
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Serializes meta.json writes so two room mutations never collide on the
|
|
247
|
+
* shared .tmp path (mirrors saveQueue for the manifest). */
|
|
248
|
+
private metaQueue: Promise<void> = Promise.resolve()
|
|
249
|
+
|
|
250
|
+
/** Write `sessions/<roomId>/meta.json` from the live entry. Atomic
|
|
251
|
+
* (write .tmp + rename), serialized, best-effort (a failure is logged, never
|
|
252
|
+
* thrown), and awaitable for tests. The dir/path are snapshotted at call time
|
|
253
|
+
* so a queued write can't drift onto a different sessionsDir (matters in tests). */
|
|
254
|
+
saveRoomMeta(roomId: string): Promise<void> {
|
|
255
|
+
const e = this.rooms.get(roomId)
|
|
256
|
+
if (!e) return Promise.resolve()
|
|
257
|
+
const workspaceDir = this.durableScope(e)
|
|
258
|
+
const meta: RoomMeta = {
|
|
259
|
+
roomId,
|
|
260
|
+
name: e.name,
|
|
261
|
+
createdAt: e.createdAt,
|
|
262
|
+
...(workspaceDir ? { workspaceDir } : {}),
|
|
263
|
+
}
|
|
264
|
+
const dir = resolve(config.sessionsDir, roomId)
|
|
265
|
+
const path = resolve(dir, "meta.json")
|
|
266
|
+
this.metaQueue = this.metaQueue
|
|
267
|
+
.then(() => this.writeRoomMeta(dir, path, meta))
|
|
268
|
+
.catch((err) => {
|
|
269
|
+
console.error(
|
|
270
|
+
`[room-meta] save failed for "${roomId}": ${err instanceof Error ? err.message : String(err)}`,
|
|
271
|
+
)
|
|
272
|
+
})
|
|
273
|
+
return this.metaQueue
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async writeRoomMeta(dir: string, path: string, meta: RoomMeta): Promise<void> {
|
|
277
|
+
const tmp = `${path}.tmp`
|
|
278
|
+
await mkdir(dir, { recursive: true })
|
|
279
|
+
await writeFile(tmp, JSON.stringify(meta, null, 2), "utf8")
|
|
280
|
+
await rename(tmp, path)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Read `sessions/<roomId>/meta.json`. Returns null when absent or malformed
|
|
284
|
+
* (e.g. a legacy orphan dir predating meta.json). */
|
|
285
|
+
async readRoomMeta(roomId: string): Promise<RoomMeta | null> {
|
|
286
|
+
try {
|
|
287
|
+
const raw = await readFile(this.roomMetaPath(roomId), "utf8")
|
|
288
|
+
const m: unknown = JSON.parse(raw)
|
|
289
|
+
if (m && typeof m === "object" && typeof (m as RoomMeta).name === "string") {
|
|
290
|
+
return m as RoomMeta
|
|
291
|
+
}
|
|
292
|
+
return null
|
|
293
|
+
} catch {
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** List rooms that have on-disk session data but are NOT currently live — i.e.
|
|
299
|
+
* destroyed/closed rooms whose conversation can be resumed. Reads meta.json for
|
|
300
|
+
* the durable name/scope and the latest conversation for activity + size.
|
|
301
|
+
* Legacy orphans (no meta.json) fall back to the latest conversation's title. */
|
|
302
|
+
async listResumableRooms(): Promise<ResumableRoom[]> {
|
|
303
|
+
const dir = config.sessionsDir
|
|
304
|
+
let entries: Dirent[]
|
|
305
|
+
try {
|
|
306
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
307
|
+
} catch {
|
|
308
|
+
return []
|
|
309
|
+
}
|
|
310
|
+
const out: ResumableRoom[] = []
|
|
311
|
+
for (const ent of entries) {
|
|
312
|
+
if (!ent.isDirectory()) continue
|
|
313
|
+
const roomId = ent.name
|
|
314
|
+
if (roomId === "default") continue
|
|
315
|
+
if (this.rooms.has(roomId)) continue // live → already a tab, not "resumable"
|
|
316
|
+
|
|
317
|
+
const meta = await this.readRoomMeta(roomId)
|
|
318
|
+
const store = new ConversationStore(resolve(dir, roomId))
|
|
319
|
+
const convs = await store.list()
|
|
320
|
+
const latest = convs[0]
|
|
321
|
+
if (!meta && !latest) continue // empty dir — nothing to resume
|
|
322
|
+
|
|
323
|
+
out.push({
|
|
324
|
+
roomId,
|
|
325
|
+
name: meta?.name ?? latest?.title ?? roomId,
|
|
326
|
+
workspaceDir: meta?.workspaceDir,
|
|
327
|
+
lastActivity: latest?.updatedAt ?? meta?.createdAt ?? 0,
|
|
328
|
+
messageCount: latest?.messageCount ?? 0,
|
|
329
|
+
hasMeta: !!meta,
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
out.sort((a, b) => b.lastActivity - a.lastActivity)
|
|
333
|
+
return out
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Resolve once all queued manifest + room-meta writes have drained. createRoom,
|
|
337
|
+
* renameRoom, and destroyRoom trigger these writes fire-and-forget (`void`), so
|
|
338
|
+
* shutdown and tests use this to wait them out before tearing down the
|
|
339
|
+
* sessions dir — otherwise an in-flight write can race the cleanup. */
|
|
340
|
+
async flushWrites(): Promise<void> {
|
|
341
|
+
await this.saveQueue
|
|
342
|
+
await this.metaQueue
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Persistence ─────────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/** Absolute path of the manifest file, colocated with per-room session dirs. */
|
|
348
|
+
private manifestPath(): string {
|
|
349
|
+
return resolve(config.sessionsDir, "rooms.json")
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Snapshot the current room set into manifest entries. */
|
|
353
|
+
private manifestEntries(): RoomManifestEntry[] {
|
|
354
|
+
return [...this.rooms.entries()].map(([roomId, e]) => {
|
|
355
|
+
const entry: RoomManifestEntry = { roomId, name: e.name }
|
|
356
|
+
if (e.sshTarget) {
|
|
357
|
+
// sshfs room: store the durable target, not the ephemeral mountpoint.
|
|
358
|
+
// Read from the entry (not e.mount) so a degraded room — intended target
|
|
359
|
+
// recorded but mount currently down — still persists its sshTarget.
|
|
360
|
+
entry.sshTarget = e.sshTarget
|
|
361
|
+
} else if (e.workspaceDir !== config.workspaceDir) {
|
|
362
|
+
// Custom local-path scope: store the path. Default scope stores nothing.
|
|
363
|
+
entry.workspaceDir = e.workspaceDir
|
|
364
|
+
}
|
|
365
|
+
return entry
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Persist the current room set. Serialized through saveQueue and best-effort:
|
|
370
|
+
* a write failure is logged, never thrown — durability must not break a room
|
|
371
|
+
* mutation. Atomic (write .tmp, rename) so a crash mid-write can't corrupt.
|
|
372
|
+
*
|
|
373
|
+
* The target dir, path, and entries are all snapshotted *now* (at call time),
|
|
374
|
+
* not when the queued write runs. This makes each save a pure snapshot of the
|
|
375
|
+
* moment it was triggered — the write can't drift onto a different sessionsDir
|
|
376
|
+
* if config changes underneath the async queue (matters in tests; in prod
|
|
377
|
+
* sessionsDir is constant). */
|
|
378
|
+
saveManifest(): Promise<void> {
|
|
379
|
+
const dir = config.sessionsDir
|
|
380
|
+
const path = resolve(dir, "rooms.json")
|
|
381
|
+
const entries = this.manifestEntries()
|
|
382
|
+
this.saveQueue = this.saveQueue
|
|
383
|
+
.then(() => this.writeManifest(dir, path, entries))
|
|
384
|
+
.catch((err) => {
|
|
385
|
+
console.error(
|
|
386
|
+
`[room-manifest] save failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
387
|
+
)
|
|
388
|
+
})
|
|
389
|
+
return this.saveQueue
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private async writeManifest(
|
|
393
|
+
dir: string,
|
|
394
|
+
path: string,
|
|
395
|
+
entries: RoomManifestEntry[],
|
|
396
|
+
): Promise<void> {
|
|
397
|
+
const tmp = `${path}.tmp`
|
|
398
|
+
await mkdir(dir, { recursive: true })
|
|
399
|
+
await writeFile(tmp, JSON.stringify(entries, null, 2), "utf8")
|
|
400
|
+
await rename(tmp, path)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Read and validate the manifest. Returns [] on missing or corrupt file —
|
|
404
|
+
* a bad manifest must never block startup (fall back to default-only). */
|
|
405
|
+
async loadManifest(): Promise<RoomManifestEntry[]> {
|
|
406
|
+
try {
|
|
407
|
+
const raw = await readFile(this.manifestPath(), "utf8")
|
|
408
|
+
const parsed: unknown = JSON.parse(raw)
|
|
409
|
+
if (!Array.isArray(parsed)) return []
|
|
410
|
+
return parsed.filter(
|
|
411
|
+
(e): e is RoomManifestEntry =>
|
|
412
|
+
!!e &&
|
|
413
|
+
typeof e === "object" &&
|
|
414
|
+
typeof (e as RoomManifestEntry).roomId === "string" &&
|
|
415
|
+
typeof (e as RoomManifestEntry).name === "string",
|
|
416
|
+
)
|
|
417
|
+
} catch {
|
|
418
|
+
return []
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Re-create non-default rooms from the manifest at startup. The default room
|
|
423
|
+
* must already exist (createDefaultRoom) — for it we only restore a renamed
|
|
424
|
+
* name. sshfs rooms are re-mounted; a mount failure degrades the room to the
|
|
425
|
+
* pipeline workspace (logged) rather than losing it. Each restored room is
|
|
426
|
+
* init()'d so its saved conversation reloads. */
|
|
427
|
+
async restoreRooms(): Promise<void> {
|
|
428
|
+
const entries = await this.loadManifest()
|
|
429
|
+
for (const entry of entries) {
|
|
430
|
+
if (entry.roomId === "default") {
|
|
431
|
+
const def = this.rooms.get("default")
|
|
432
|
+
if (def && def.name !== entry.name) this.renameRoom("default", entry.name)
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
435
|
+
if (this.rooms.has(entry.roomId)) continue
|
|
436
|
+
|
|
437
|
+
let workspaceDir: string | undefined = entry.workspaceDir
|
|
438
|
+
let mount: RoomMount | undefined
|
|
439
|
+
if (entry.sshTarget) {
|
|
440
|
+
try {
|
|
441
|
+
const mountpoint = await mountSshfs(entry.roomId, entry.sshTarget)
|
|
442
|
+
workspaceDir = mountpoint
|
|
443
|
+
mount = { mountpoint, sshTarget: entry.sshTarget }
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.warn(
|
|
446
|
+
`[room-restore] sshfs mount failed for "${entry.roomId}" (${entry.sshTarget}): ` +
|
|
447
|
+
`${err instanceof Error ? err.message : String(err)}. ` +
|
|
448
|
+
`Restoring in degraded mode (pipeline workspace).`,
|
|
449
|
+
)
|
|
450
|
+
workspaceDir = undefined
|
|
451
|
+
mount = undefined
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
// Pass entry.sshTarget so a degraded room (mount === undefined) still
|
|
457
|
+
// records its intended target and survives the next save/restart.
|
|
458
|
+
const room = this.createRoom(
|
|
459
|
+
entry.roomId,
|
|
460
|
+
entry.name,
|
|
461
|
+
undefined,
|
|
462
|
+
workspaceDir,
|
|
463
|
+
mount,
|
|
464
|
+
entry.sshTarget,
|
|
465
|
+
)
|
|
466
|
+
await room.init()
|
|
467
|
+
console.log(`[room-restore] restored "${entry.roomId}" (${entry.name})`)
|
|
468
|
+
} catch (err) {
|
|
469
|
+
console.error(
|
|
470
|
+
`[room-restore] failed to restore "${entry.roomId}": ` +
|
|
471
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
472
|
+
)
|
|
473
|
+
if (mount) await unmountSshfs(mount.mountpoint).catch(() => {})
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Snapshot list of all rooms. */
|
|
479
|
+
listRooms(): RoomSummary[] {
|
|
480
|
+
return [...this.rooms.entries()].map(([id, { room, name }]) => ({
|
|
481
|
+
roomId: id,
|
|
482
|
+
name,
|
|
483
|
+
participantCount: room.rosterLength(),
|
|
484
|
+
goalStatus: room.getGoalStatus(),
|
|
485
|
+
goalText: room.getGoalText(),
|
|
486
|
+
}))
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Full details for one room. Returns undefined when not found. */
|
|
490
|
+
getRoomDetails(roomId: string): RoomDetails | undefined {
|
|
491
|
+
const entry = this.rooms.get(roomId)
|
|
492
|
+
if (!entry) return undefined
|
|
493
|
+
const { room, name, workspaceDir } = entry
|
|
494
|
+
return {
|
|
495
|
+
roomId,
|
|
496
|
+
name,
|
|
497
|
+
participantCount: room.rosterLength(),
|
|
498
|
+
goalStatus: room.getGoalStatus(),
|
|
499
|
+
goalText: room.getGoalText(),
|
|
500
|
+
isBusy: room.isBusy(),
|
|
501
|
+
transcriptLength: room.getTranscript().length,
|
|
502
|
+
workspaceDir,
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|