@hraness/dawg 0.0.0-stage → 0.2.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/CHANGELOG.md +47 -0
- package/DAWG.md +142 -0
- package/LICENSE +21 -0
- package/README.md +164 -2
- package/core/drums.ts +102 -0
- package/core/loop.ts +78 -0
- package/core/score.ts +1065 -0
- package/package.json +42 -4
- package/src/agent/agent.ts +587 -0
- package/src/agent/brief.ts +140 -0
- package/src/agent/gateway.ts +267 -0
- package/src/agent/ops.ts +172 -0
- package/src/agent/planner.ts +221 -0
- package/src/agent/provider.ts +326 -0
- package/src/agent/sse.ts +90 -0
- package/src/agent/tools.ts +976 -0
- package/src/agent/xcb-agent.ts +247 -0
- package/src/agent/xcb.ts +367 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +467 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +119 -0
- package/src/audio/wav.ts +647 -0
- package/src/auth/cli.ts +112 -0
- package/src/auth/credentials.ts +285 -0
- package/src/auth/login.ts +434 -0
- package/src/auth/runner.ts +184 -0
- package/src/auth/tui.ts +90 -0
- package/src/commands/history.ts +56 -0
- package/src/commands/music.ts +444 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +1332 -0
- package/src/render.ts +99 -0
- package/src/session/attach.ts +182 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +723 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +464 -0
- package/src/session/port.ts +461 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +138 -0
- package/src/session/store.ts +486 -0
- package/tui/activity.ts +325 -0
- package/tui/app.ts +1129 -0
- package/tui/drums.ts +21 -0
- package/tui/highway.ts +905 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +68 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { watch, type FSWatcher } from "node:fs";
|
|
3
|
+
import { basename, dirname } from "node:path";
|
|
4
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
5
|
+
import { AudioEngine } from "../audio/engine.ts";
|
|
6
|
+
import { DaemonClient, type DaemonClientOptions } from "./client.ts";
|
|
7
|
+
import { FilePresence } from "./presence.ts";
|
|
8
|
+
import type {
|
|
9
|
+
PresenceEntry,
|
|
10
|
+
TransportAction,
|
|
11
|
+
TransportState,
|
|
12
|
+
} from "./protocol.ts";
|
|
13
|
+
import type { MetaExpect, MetaPatch, SessionMeta } from "./meta.ts";
|
|
14
|
+
import {
|
|
15
|
+
appendSessionEvent,
|
|
16
|
+
loadSession,
|
|
17
|
+
SessionConflictError,
|
|
18
|
+
updateSessionMeta,
|
|
19
|
+
type SessionEvent,
|
|
20
|
+
type SessionPaths,
|
|
21
|
+
type SessionRecord,
|
|
22
|
+
} from "./store.ts";
|
|
23
|
+
|
|
24
|
+
/** File-port polling: without fs.watch, and as a backstop with it. */
|
|
25
|
+
const POLL_MS = 200;
|
|
26
|
+
const WATCHED_POLL_MS = 1_000;
|
|
27
|
+
/** Mirrors the protocol's per-intent operation cap. */
|
|
28
|
+
const MAX_INTENT_OPERATIONS = 256;
|
|
29
|
+
|
|
30
|
+
export type PortUpdate<T> =
|
|
31
|
+
| { type: "record"; record: SessionRecord<T> }
|
|
32
|
+
| { type: "transport"; transport: TransportState }
|
|
33
|
+
| { type: "presence"; clients: PresenceEntry[] }
|
|
34
|
+
/** Name or lineage changed; the score revision did not. */
|
|
35
|
+
| { type: "meta"; meta: SessionMeta }
|
|
36
|
+
/** Structured sync state for the header; no activity card. */
|
|
37
|
+
| { type: "sync"; sync: SyncStatus }
|
|
38
|
+
| { type: "status"; message: string };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `synced` connected and idle, `syncing` a write is in flight, `conflict` the
|
|
42
|
+
* last write raced another window and must rebase, `offline` dawgd dropped
|
|
43
|
+
* and is reconnecting, `local` file-lock fallback (no daemon).
|
|
44
|
+
*/
|
|
45
|
+
export type SyncStatus =
|
|
46
|
+
"synced" | "syncing" | "conflict" | "offline" | "local";
|
|
47
|
+
|
|
48
|
+
export type MetaWriteResult = {
|
|
49
|
+
status: "applied" | "stale";
|
|
50
|
+
/** The metadata now in effect (the winner's, when stale). */
|
|
51
|
+
meta: SessionMeta;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** What a window plays locally. Connected windows never start audio. */
|
|
55
|
+
export type WindowPlayer = {
|
|
56
|
+
/** Start at `beat`, or swap a playing loop in place (gapless engine). */
|
|
57
|
+
play(score: TrackScore, beat?: number): Promise<void>;
|
|
58
|
+
stop(): void;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The seam between the TUI and session persistence. `daemon` talks to dawgd
|
|
63
|
+
* (single writer, single transport, pushed updates); `file` is the original
|
|
64
|
+
* file-lock path with snapshot polling, kept as the never-break fallback.
|
|
65
|
+
*/
|
|
66
|
+
export interface SessionPort<T> {
|
|
67
|
+
readonly mode: "daemon" | "file";
|
|
68
|
+
/** Human-readable status for the activity line. */
|
|
69
|
+
readonly status: string;
|
|
70
|
+
readonly player: WindowPlayer;
|
|
71
|
+
readonly sync: SyncStatus;
|
|
72
|
+
/** Appends an event at `current.revision`; throws SessionConflictError when stale. */
|
|
73
|
+
append(
|
|
74
|
+
current: SessionRecord<T>,
|
|
75
|
+
event: Omit<SessionEvent, "id" | "revision" | "at">,
|
|
76
|
+
composition: T,
|
|
77
|
+
): Promise<SessionRecord<T>>;
|
|
78
|
+
/**
|
|
79
|
+
* Commits score operations validated against `current`. dawgd replays them
|
|
80
|
+
* on a newer score when nothing they touch changed (so a stale base is not
|
|
81
|
+
* a conflict); the file port commits `composition` exactly like `append`.
|
|
82
|
+
* The returned record may therefore be ahead of `composition`.
|
|
83
|
+
*/
|
|
84
|
+
appendOperations(
|
|
85
|
+
current: SessionRecord<T>,
|
|
86
|
+
event: Omit<SessionEvent, "id" | "revision" | "at">,
|
|
87
|
+
operations: readonly unknown[],
|
|
88
|
+
composition: T,
|
|
89
|
+
): Promise<SessionRecord<T>>;
|
|
90
|
+
load(): Promise<SessionRecord<T>>;
|
|
91
|
+
/** Daemon mode only: asks dawgd to change the shared transport. */
|
|
92
|
+
transport(
|
|
93
|
+
action: TransportAction,
|
|
94
|
+
value?: { beat?: number; bpm?: number },
|
|
95
|
+
): Promise<void>;
|
|
96
|
+
subscribe(listener: (update: PortUpdate<T>) => void): () => void;
|
|
97
|
+
focus(trackId: string | null): Promise<void>;
|
|
98
|
+
/** Atomically claims a track that no other live window has focused. */
|
|
99
|
+
claimTrack(
|
|
100
|
+
trackIds: readonly string[],
|
|
101
|
+
preferred?: string,
|
|
102
|
+
): Promise<string | null>;
|
|
103
|
+
/**
|
|
104
|
+
* `claimTrack` with a draft fallback: when every track is focused, reserve
|
|
105
|
+
* a new `track-N` id (not yet in the score) for this window.
|
|
106
|
+
*/
|
|
107
|
+
claimOrDraft(
|
|
108
|
+
trackIds: readonly string[],
|
|
109
|
+
preferred?: string,
|
|
110
|
+
): Promise<{ trackId: string; draft: boolean }>;
|
|
111
|
+
presence(): Promise<PresenceEntry[]>;
|
|
112
|
+
/**
|
|
113
|
+
* Conditional metadata write: applies only while `expect` matches the
|
|
114
|
+
* stored metadata, so a user rename always beats an in-flight auto-name.
|
|
115
|
+
*/
|
|
116
|
+
updateMeta(patch: MetaPatch, expect?: MetaExpect): Promise<MetaWriteResult>;
|
|
117
|
+
close(): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type OpenPortOptions = {
|
|
121
|
+
paths: SessionPaths;
|
|
122
|
+
sessionId: string;
|
|
123
|
+
label: string;
|
|
124
|
+
focusedTrackId: string | null;
|
|
125
|
+
/** Set false for one-shot commands (demo, export) that must not spawn dawgd. */
|
|
126
|
+
daemon?: boolean;
|
|
127
|
+
daemonArgs?: string[];
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Connects to (or starts) dawgd, falling back to the file-lock path. */
|
|
131
|
+
export async function openSessionPort<T>(
|
|
132
|
+
options: OpenPortOptions,
|
|
133
|
+
): Promise<SessionPort<T>> {
|
|
134
|
+
if (options.daemon !== false && process.env.DAWG_DAEMON !== "0") {
|
|
135
|
+
try {
|
|
136
|
+
const clientOptions: DaemonClientOptions = {
|
|
137
|
+
workspace: dirname(options.paths.root),
|
|
138
|
+
sessionId: options.sessionId,
|
|
139
|
+
label: options.label,
|
|
140
|
+
focusedTrackId: options.focusedTrackId,
|
|
141
|
+
};
|
|
142
|
+
if (options.daemonArgs) clientOptions.daemonArgs = options.daemonArgs;
|
|
143
|
+
const client = await DaemonClient.connect(clientOptions);
|
|
144
|
+
return new DaemonPort<T>(client);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
147
|
+
const port = await FilePort.open<T>(options);
|
|
148
|
+
port.status = `dawgd unavailable (${reason}); using file lock`;
|
|
149
|
+
return port;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const port = await FilePort.open<T>(options);
|
|
153
|
+
port.status = "file session";
|
|
154
|
+
return port;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const silentPlayer: WindowPlayer = {
|
|
158
|
+
play: async () => undefined,
|
|
159
|
+
stop: () => undefined,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
class DaemonPort<T> implements SessionPort<T> {
|
|
163
|
+
public readonly mode = "daemon" as const;
|
|
164
|
+
public readonly player = silentPlayer;
|
|
165
|
+
public status: string;
|
|
166
|
+
public sync: SyncStatus = "synced";
|
|
167
|
+
private readonly syncListeners = new Set<(sync: SyncStatus) => void>();
|
|
168
|
+
|
|
169
|
+
public constructor(private readonly client: DaemonClient) {
|
|
170
|
+
this.status = `dawgd pid ${client.daemonPid}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private setSync(sync: SyncStatus): void {
|
|
174
|
+
if (this.sync === sync) return;
|
|
175
|
+
this.sync = sync;
|
|
176
|
+
for (const listener of this.syncListeners) listener(sync);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
public async append(
|
|
180
|
+
current: SessionRecord<T>,
|
|
181
|
+
event: Omit<SessionEvent, "id" | "revision" | "at">,
|
|
182
|
+
composition: T,
|
|
183
|
+
): Promise<SessionRecord<T>> {
|
|
184
|
+
// Transport lives in dawgd's clock, not in the event log, when connected.
|
|
185
|
+
if (event.kind === "transport") return this.record();
|
|
186
|
+
return this.applyIntent(current, event, { composition });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
public async appendOperations(
|
|
190
|
+
current: SessionRecord<T>,
|
|
191
|
+
event: Omit<SessionEvent, "id" | "revision" | "at">,
|
|
192
|
+
operations: readonly unknown[],
|
|
193
|
+
composition: T,
|
|
194
|
+
): Promise<SessionRecord<T>> {
|
|
195
|
+
if (operations.length === 0 || operations.length > MAX_INTENT_OPERATIONS)
|
|
196
|
+
return this.append(current, event, composition);
|
|
197
|
+
return this.applyIntent(current, event, { operations: [...operations] });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private async applyIntent(
|
|
201
|
+
current: SessionRecord<T>,
|
|
202
|
+
event: Omit<SessionEvent, "id" | "revision" | "at">,
|
|
203
|
+
body: { composition: T } | { operations: unknown[] },
|
|
204
|
+
): Promise<SessionRecord<T>> {
|
|
205
|
+
if (this.client.connected) this.setSync("syncing");
|
|
206
|
+
let result;
|
|
207
|
+
try {
|
|
208
|
+
result = await this.client.apply({
|
|
209
|
+
base: current.revision,
|
|
210
|
+
kind: event.kind,
|
|
211
|
+
payload: event.payload,
|
|
212
|
+
...body,
|
|
213
|
+
key: randomUUID(),
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
this.setSync(this.client.connected ? "synced" : "offline");
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
if (result.status === "accepted" || result.status === "duplicate") {
|
|
220
|
+
this.setSync("synced");
|
|
221
|
+
return this.record();
|
|
222
|
+
}
|
|
223
|
+
if (result.status === "rebase") {
|
|
224
|
+
this.setSync("conflict");
|
|
225
|
+
throw new SessionConflictError();
|
|
226
|
+
}
|
|
227
|
+
this.setSync("synced");
|
|
228
|
+
throw new Error(`dawgd rejected ${event.kind}: ${result.message}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
public async load(): Promise<SessionRecord<T>> {
|
|
232
|
+
await this.client.sync();
|
|
233
|
+
return this.record();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
public async transport(
|
|
237
|
+
action: TransportAction,
|
|
238
|
+
value?: { beat?: number; bpm?: number },
|
|
239
|
+
): Promise<void> {
|
|
240
|
+
await this.client.setTransport(action, value);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
public subscribe(listener: (update: PortUpdate<T>) => void): () => void {
|
|
244
|
+
listener({ type: "transport", transport: this.client.transport });
|
|
245
|
+
const onSync = (sync: SyncStatus) => listener({ type: "sync", sync });
|
|
246
|
+
this.syncListeners.add(onSync);
|
|
247
|
+
const unsubscribe = this.client.subscribe((update) => {
|
|
248
|
+
if (update.type === "record") {
|
|
249
|
+
// A fresh record means this window has caught up after a conflict.
|
|
250
|
+
if (this.sync === "conflict") this.setSync("synced");
|
|
251
|
+
listener({ type: "record", record: update.record as SessionRecord<T> });
|
|
252
|
+
} else if (update.type === "status") {
|
|
253
|
+
this.status = update.message;
|
|
254
|
+
this.setSync(update.connected ? "synced" : "offline");
|
|
255
|
+
listener({ type: "status", message: update.message });
|
|
256
|
+
} else listener(update);
|
|
257
|
+
});
|
|
258
|
+
return () => {
|
|
259
|
+
this.syncListeners.delete(onSync);
|
|
260
|
+
unsubscribe();
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
public focus(trackId: string | null): Promise<void> {
|
|
265
|
+
return this.client.focus(trackId);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
public claimTrack(
|
|
269
|
+
_trackIds: readonly string[],
|
|
270
|
+
preferred?: string,
|
|
271
|
+
): Promise<string | null> {
|
|
272
|
+
// dawgd claims against its own authoritative score order.
|
|
273
|
+
return this.client.claimTrack(preferred);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
public claimOrDraft(
|
|
277
|
+
_trackIds: readonly string[],
|
|
278
|
+
preferred?: string,
|
|
279
|
+
): Promise<{ trackId: string; draft: boolean }> {
|
|
280
|
+
return this.client.claimOrDraft(preferred);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
public async presence(): Promise<PresenceEntry[]> {
|
|
284
|
+
return this.client.presence;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
public async updateMeta(
|
|
288
|
+
patch: MetaPatch,
|
|
289
|
+
expect?: MetaExpect,
|
|
290
|
+
): Promise<MetaWriteResult> {
|
|
291
|
+
const status = await this.client.updateMeta(patch, expect);
|
|
292
|
+
return { status, meta: this.client.record.meta };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
public async close(): Promise<void> {
|
|
296
|
+
this.client.close();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private record(): SessionRecord<T> {
|
|
300
|
+
return this.client.record as SessionRecord<T>;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
class FilePort<T> implements SessionPort<T> {
|
|
305
|
+
public readonly mode = "file" as const;
|
|
306
|
+
public status = "file session";
|
|
307
|
+
public readonly sync = "local" as const;
|
|
308
|
+
public readonly player: WindowPlayer;
|
|
309
|
+
private readonly presenceStore: FilePresence;
|
|
310
|
+
|
|
311
|
+
private constructor(private readonly options: OpenPortOptions) {
|
|
312
|
+
this.player = new AudioEngine({
|
|
313
|
+
lockPath: `${options.paths.record}.audio.lock`,
|
|
314
|
+
});
|
|
315
|
+
this.presenceStore = new FilePresence(options.paths, {
|
|
316
|
+
clientId: randomUUID(),
|
|
317
|
+
pid: process.pid,
|
|
318
|
+
label: options.label.slice(0, 128),
|
|
319
|
+
focusedTrackId: options.focusedTrackId,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
public static async open<T>(options: OpenPortOptions): Promise<FilePort<T>> {
|
|
324
|
+
const port = new FilePort<T>(options);
|
|
325
|
+
if (options.daemon !== false)
|
|
326
|
+
await port.presenceStore.start().catch(() => undefined);
|
|
327
|
+
return port;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
public append(
|
|
331
|
+
current: SessionRecord<T>,
|
|
332
|
+
event: Omit<SessionEvent, "id" | "revision" | "at">,
|
|
333
|
+
composition: T,
|
|
334
|
+
): Promise<SessionRecord<T>> {
|
|
335
|
+
return appendSessionEvent(this.options.paths, current, event, composition);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** No daemon to rebase against: commit the full composition. */
|
|
339
|
+
public appendOperations(
|
|
340
|
+
current: SessionRecord<T>,
|
|
341
|
+
event: Omit<SessionEvent, "id" | "revision" | "at">,
|
|
342
|
+
_operations: readonly unknown[],
|
|
343
|
+
composition: T,
|
|
344
|
+
): Promise<SessionRecord<T>> {
|
|
345
|
+
return this.append(current, event, composition);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
public load(): Promise<SessionRecord<T>> {
|
|
349
|
+
return loadSession<T>(this.options.paths);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
public async transport(): Promise<void> {
|
|
353
|
+
throw new Error("file sessions drive transport locally");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Without dawgd, changes arrive by watching the session directory (the
|
|
358
|
+
* record is replaced by atomic rename, so the file itself cannot be watched)
|
|
359
|
+
* and re-reading on each event: renames and edits from other windows show up
|
|
360
|
+
* immediately. Polling stays on as the fallback, fast when fs.watch is
|
|
361
|
+
* unavailable and slow as a backstop for filesystems that drop events.
|
|
362
|
+
*/
|
|
363
|
+
public subscribe(listener: (update: PortUpdate<T>) => void): () => void {
|
|
364
|
+
let revision = -1;
|
|
365
|
+
let metaVersion = -1;
|
|
366
|
+
let busy = false;
|
|
367
|
+
let again = false;
|
|
368
|
+
let closed = false;
|
|
369
|
+
const check = (): void => {
|
|
370
|
+
if (closed) return;
|
|
371
|
+
if (busy) {
|
|
372
|
+
again = true;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
busy = true;
|
|
376
|
+
void this.load()
|
|
377
|
+
.then((record) => {
|
|
378
|
+
if (closed) return;
|
|
379
|
+
if (record.revision > revision) listener({ type: "record", record });
|
|
380
|
+
else if (metaVersion >= 0 && record.meta.version > metaVersion)
|
|
381
|
+
listener({ type: "meta", meta: record.meta });
|
|
382
|
+
revision = Math.max(revision, record.revision);
|
|
383
|
+
metaVersion = Math.max(metaVersion, record.meta.version);
|
|
384
|
+
})
|
|
385
|
+
// A partially written or concurrently replaced snapshot is retried next tick.
|
|
386
|
+
.catch(() => undefined)
|
|
387
|
+
.finally(() => {
|
|
388
|
+
busy = false;
|
|
389
|
+
if (again) {
|
|
390
|
+
again = false;
|
|
391
|
+
check();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
};
|
|
395
|
+
const recordName = basename(this.options.paths.record);
|
|
396
|
+
let watcher: FSWatcher | undefined;
|
|
397
|
+
try {
|
|
398
|
+
watcher = watch(dirname(this.options.paths.record), (_event, file) => {
|
|
399
|
+
// macOS coalesces the temp write and the rename into events named
|
|
400
|
+
// after the temp file (`<record>.<pid>.tmp`), so match the prefix and
|
|
401
|
+
// re-check shortly after in case the event preceded the rename.
|
|
402
|
+
if (file !== null && !String(file).startsWith(recordName)) return;
|
|
403
|
+
check();
|
|
404
|
+
setTimeout(check, 40).unref?.();
|
|
405
|
+
});
|
|
406
|
+
watcher.on("error", () => {
|
|
407
|
+
watcher?.close();
|
|
408
|
+
watcher = undefined;
|
|
409
|
+
});
|
|
410
|
+
watcher.unref?.();
|
|
411
|
+
} catch {
|
|
412
|
+
watcher = undefined;
|
|
413
|
+
}
|
|
414
|
+
const timer = setInterval(check, watcher ? WATCHED_POLL_MS : POLL_MS);
|
|
415
|
+
check();
|
|
416
|
+
return () => {
|
|
417
|
+
closed = true;
|
|
418
|
+
clearInterval(timer);
|
|
419
|
+
watcher?.close();
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
public focus(trackId: string | null): Promise<void> {
|
|
424
|
+
return this.presenceStore.focus(trackId);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
public claimTrack(
|
|
428
|
+
trackIds: readonly string[],
|
|
429
|
+
preferred?: string,
|
|
430
|
+
): Promise<string | null> {
|
|
431
|
+
return this.presenceStore.claim(trackIds, preferred);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
public claimOrDraft(
|
|
435
|
+
trackIds: readonly string[],
|
|
436
|
+
preferred?: string,
|
|
437
|
+
): Promise<{ trackId: string; draft: boolean }> {
|
|
438
|
+
return this.presenceStore.claimOrDraft(trackIds, preferred);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
public presence(): Promise<PresenceEntry[]> {
|
|
442
|
+
return this.presenceStore.list();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
public async updateMeta(
|
|
446
|
+
patch: MetaPatch,
|
|
447
|
+
expect?: MetaExpect,
|
|
448
|
+
): Promise<MetaWriteResult> {
|
|
449
|
+
const result = await updateSessionMeta<T>(
|
|
450
|
+
this.options.paths,
|
|
451
|
+
patch,
|
|
452
|
+
expect,
|
|
453
|
+
);
|
|
454
|
+
return { status: result.status, meta: result.record.meta };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
public async close(): Promise<void> {
|
|
458
|
+
this.player.stop();
|
|
459
|
+
await this.presenceStore.stop();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
readdir,
|
|
4
|
+
readFile,
|
|
5
|
+
rename,
|
|
6
|
+
rm,
|
|
7
|
+
writeFile,
|
|
8
|
+
} from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { acquireSessionLock } from "./lock.ts";
|
|
11
|
+
import {
|
|
12
|
+
chooseTrack,
|
|
13
|
+
claimOrDraft,
|
|
14
|
+
requirePresence,
|
|
15
|
+
type PresenceEntry,
|
|
16
|
+
} from "./protocol.ts";
|
|
17
|
+
import type { SessionPaths } from "./store.ts";
|
|
18
|
+
|
|
19
|
+
/** Heartbeats are refreshed every second and expire after this window. */
|
|
20
|
+
export const PRESENCE_STALE_MS = 5_000;
|
|
21
|
+
const HEARTBEAT_MS = 1_000;
|
|
22
|
+
const MAX_PRESENCE_FILE_BYTES = 4 * 1024;
|
|
23
|
+
const MAX_PRESENCE_FILES = 256;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Presence for windows on the file-lock fallback: one heartbeat file per
|
|
27
|
+
* client under the session directory. Claims are serialized by a lock so two
|
|
28
|
+
* windows opening at once cannot pick the same track.
|
|
29
|
+
*/
|
|
30
|
+
export class FilePresence {
|
|
31
|
+
private readonly dir: string;
|
|
32
|
+
private readonly lock: string;
|
|
33
|
+
private entry: PresenceEntry;
|
|
34
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
35
|
+
|
|
36
|
+
public constructor(paths: SessionPaths, entry: PresenceEntry) {
|
|
37
|
+
this.dir = `${paths.record}.presence`;
|
|
38
|
+
this.lock = `${paths.record}.presence.lock`;
|
|
39
|
+
this.entry = entry;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public async start(): Promise<void> {
|
|
43
|
+
await this.write();
|
|
44
|
+
this.timer = setInterval(
|
|
45
|
+
() => void this.write().catch(() => {}),
|
|
46
|
+
HEARTBEAT_MS,
|
|
47
|
+
);
|
|
48
|
+
this.timer.unref?.();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public async stop(): Promise<void> {
|
|
52
|
+
if (this.timer) clearInterval(this.timer);
|
|
53
|
+
this.timer = undefined;
|
|
54
|
+
await rm(this.file(), { force: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public async focus(trackId: string | null): Promise<void> {
|
|
58
|
+
this.entry = { ...this.entry, focusedTrackId: trackId };
|
|
59
|
+
await this.write();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public async claim(
|
|
63
|
+
trackIds: readonly string[],
|
|
64
|
+
preferred?: string,
|
|
65
|
+
): Promise<string | null> {
|
|
66
|
+
const release = await acquireSessionLock(this.lock);
|
|
67
|
+
try {
|
|
68
|
+
const taken = new Set<string>();
|
|
69
|
+
for (const other of await this.list())
|
|
70
|
+
if (other.clientId !== this.entry.clientId && other.focusedTrackId)
|
|
71
|
+
taken.add(other.focusedTrackId);
|
|
72
|
+
const trackId = chooseTrack(trackIds, taken, preferred);
|
|
73
|
+
if (trackId !== null) await this.focus(trackId);
|
|
74
|
+
return trackId;
|
|
75
|
+
} finally {
|
|
76
|
+
await release();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Claim with draft fallback, serialized by the same lock as `claim`. */
|
|
81
|
+
public async claimOrDraft(
|
|
82
|
+
trackIds: readonly string[],
|
|
83
|
+
preferred?: string,
|
|
84
|
+
): Promise<{ trackId: string; draft: boolean }> {
|
|
85
|
+
const release = await acquireSessionLock(this.lock);
|
|
86
|
+
try {
|
|
87
|
+
const taken = new Set<string>();
|
|
88
|
+
for (const other of await this.list())
|
|
89
|
+
if (other.clientId !== this.entry.clientId && other.focusedTrackId)
|
|
90
|
+
taken.add(other.focusedTrackId);
|
|
91
|
+
const claim = claimOrDraft(trackIds, taken, preferred);
|
|
92
|
+
await this.focus(claim.trackId);
|
|
93
|
+
return claim;
|
|
94
|
+
} finally {
|
|
95
|
+
await release();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Live entries; stale or dead-process heartbeats are removed. */
|
|
100
|
+
public async list(now = Date.now()): Promise<PresenceEntry[]> {
|
|
101
|
+
let names: string[];
|
|
102
|
+
try {
|
|
103
|
+
names = (await readdir(this.dir)).filter((name) =>
|
|
104
|
+
name.endsWith(".json"),
|
|
105
|
+
);
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
const entries: PresenceEntry[] = [];
|
|
110
|
+
for (const name of names.slice(0, MAX_PRESENCE_FILES)) {
|
|
111
|
+
const path = join(this.dir, name);
|
|
112
|
+
try {
|
|
113
|
+
const text = await readFile(path, "utf8");
|
|
114
|
+
if (text.length > MAX_PRESENCE_FILE_BYTES) throw new Error("too large");
|
|
115
|
+
const value: unknown = JSON.parse(text);
|
|
116
|
+
const at = (value as { at?: unknown }).at;
|
|
117
|
+
const entry = requirePresence(value);
|
|
118
|
+
if (
|
|
119
|
+
typeof at !== "number" ||
|
|
120
|
+
now - at > PRESENCE_STALE_MS ||
|
|
121
|
+
!alive(entry.pid)
|
|
122
|
+
)
|
|
123
|
+
throw new Error("stale");
|
|
124
|
+
entries.push(entry);
|
|
125
|
+
} catch {
|
|
126
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return entries;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private file(): string {
|
|
133
|
+
return join(this.dir, `${this.entry.clientId}.json`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async write(): Promise<void> {
|
|
137
|
+
await mkdir(this.dir, { recursive: true });
|
|
138
|
+
const temporary = `${this.file()}.${process.pid}.tmp`;
|
|
139
|
+
await writeFile(
|
|
140
|
+
temporary,
|
|
141
|
+
JSON.stringify({ ...this.entry, at: Date.now() }),
|
|
142
|
+
"utf8",
|
|
143
|
+
);
|
|
144
|
+
await rename(temporary, this.file());
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function alive(pid: number): boolean {
|
|
149
|
+
try {
|
|
150
|
+
process.kill(pid, 0);
|
|
151
|
+
return true;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return !(
|
|
154
|
+
error instanceof Error &&
|
|
155
|
+
"code" in error &&
|
|
156
|
+
error.code === "ESRCH"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|