@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,723 @@
|
|
|
1
|
+
import { appendFile, rm, stat, truncate } from "node:fs/promises";
|
|
2
|
+
import { createServer, type Server, type Socket } from "node:net";
|
|
3
|
+
import {
|
|
4
|
+
applyScoreOperation,
|
|
5
|
+
scoreFromJSON,
|
|
6
|
+
type ScoreOperation,
|
|
7
|
+
type TrackScore,
|
|
8
|
+
} from "../../core/score.ts";
|
|
9
|
+
import { TransportClock } from "../audio/clock.ts";
|
|
10
|
+
import { AudioEngine } from "../audio/engine.ts";
|
|
11
|
+
import { acquireSessionLock } from "./lock.ts";
|
|
12
|
+
import {
|
|
13
|
+
compositionAt,
|
|
14
|
+
MAX_REBASE_DISTANCE,
|
|
15
|
+
rebaseOperations,
|
|
16
|
+
} from "./rebase.ts";
|
|
17
|
+
import {
|
|
18
|
+
compositionDigest,
|
|
19
|
+
daemonLockPath,
|
|
20
|
+
daemonLogPath,
|
|
21
|
+
daemonSocketPath,
|
|
22
|
+
encodeFrame,
|
|
23
|
+
LineDecoder,
|
|
24
|
+
MAX_CLIENT_FRAME_BYTES,
|
|
25
|
+
monotonicEpochMs,
|
|
26
|
+
parseClientMessage,
|
|
27
|
+
ProtocolError,
|
|
28
|
+
type ApplyResult,
|
|
29
|
+
chooseTrack,
|
|
30
|
+
claimOrDraft,
|
|
31
|
+
type ClientMessage,
|
|
32
|
+
type PresenceEntry,
|
|
33
|
+
type ServerMessage,
|
|
34
|
+
type TransportState,
|
|
35
|
+
} from "./protocol.ts";
|
|
36
|
+
import {
|
|
37
|
+
appendSessionEvent,
|
|
38
|
+
loadSession,
|
|
39
|
+
SessionConflictError,
|
|
40
|
+
sessionPaths,
|
|
41
|
+
updateSessionMeta,
|
|
42
|
+
type SessionPaths,
|
|
43
|
+
type SessionRecord,
|
|
44
|
+
} from "./store.ts";
|
|
45
|
+
|
|
46
|
+
/** A slow client that stops reading is dropped rather than buffered forever. */
|
|
47
|
+
const MAX_CLIENT_BACKLOG_BYTES = 32 * 1024 * 1024;
|
|
48
|
+
const DEFAULT_GRACE_MS = 30_000;
|
|
49
|
+
const DISK_POLL_MS = 750;
|
|
50
|
+
const MAX_LOG_BYTES = 256 * 1024;
|
|
51
|
+
|
|
52
|
+
type Composition = ReturnType<TrackScore["toJSON"]>;
|
|
53
|
+
|
|
54
|
+
type Client = {
|
|
55
|
+
socket: Socket;
|
|
56
|
+
decoder: LineDecoder;
|
|
57
|
+
ready: boolean;
|
|
58
|
+
presence?: PresenceEntry;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type DaemonOptions = {
|
|
62
|
+
workspace: string;
|
|
63
|
+
sessionId: string;
|
|
64
|
+
graceMs?: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* dawgd: the single writer and the single audio transport for one session.
|
|
69
|
+
* Clients send intents; the daemon applies them through the core reducer,
|
|
70
|
+
* persists them with the existing atomic store, and broadcasts the result.
|
|
71
|
+
*/
|
|
72
|
+
export class DawgDaemon {
|
|
73
|
+
private readonly paths: SessionPaths;
|
|
74
|
+
private readonly socketPath: string;
|
|
75
|
+
private readonly graceMs: number;
|
|
76
|
+
private readonly clients = new Set<Client>();
|
|
77
|
+
private readonly keys = new Map<string, number>();
|
|
78
|
+
private readonly clock = new TransportClock();
|
|
79
|
+
private readonly audio: AudioEngine;
|
|
80
|
+
private record!: SessionRecord<Composition>;
|
|
81
|
+
private score!: TrackScore;
|
|
82
|
+
private digest = "";
|
|
83
|
+
private seq = 0;
|
|
84
|
+
private server: Server | undefined;
|
|
85
|
+
private releaseLock: (() => Promise<void>) | undefined;
|
|
86
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
87
|
+
private graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
88
|
+
private diskTimer: ReturnType<typeof setInterval> | undefined;
|
|
89
|
+
private diskMtime = 0;
|
|
90
|
+
private stopping: Promise<void> | undefined;
|
|
91
|
+
|
|
92
|
+
public constructor(options: DaemonOptions) {
|
|
93
|
+
this.paths = sessionPaths(options.workspace, options.sessionId);
|
|
94
|
+
this.socketPath = daemonSocketPath(this.paths);
|
|
95
|
+
this.graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
|
|
96
|
+
this.audio = new AudioEngine({
|
|
97
|
+
lockPath: `${this.paths.record}.audio.lock`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Returns false when another live daemon already owns this session. */
|
|
102
|
+
public async start(): Promise<boolean> {
|
|
103
|
+
try {
|
|
104
|
+
this.releaseLock = await acquireSessionLock(
|
|
105
|
+
daemonLockPath(this.paths),
|
|
106
|
+
400,
|
|
107
|
+
);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof Error && error.message.includes("timed out"))
|
|
110
|
+
return false;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
await this.reload();
|
|
115
|
+
this.clock.setTempo(this.score.tempoBpm);
|
|
116
|
+
// We hold the daemon lock, so any socket file left here belongs to a
|
|
117
|
+
// crashed daemon and is safe to reclaim.
|
|
118
|
+
await rm(this.socketPath, { force: true });
|
|
119
|
+
this.server = createServer((socket) => this.accept(socket));
|
|
120
|
+
await new Promise<void>((resolve, reject) => {
|
|
121
|
+
this.server!.once("error", reject);
|
|
122
|
+
this.server!.listen(this.socketPath, () => {
|
|
123
|
+
this.server!.off("error", reject);
|
|
124
|
+
resolve();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
this.diskTimer = setInterval(() => void this.pollDisk(), DISK_POLL_MS);
|
|
128
|
+
this.armGrace();
|
|
129
|
+
await this.log(
|
|
130
|
+
`started pid ${process.pid} socket ${this.socketPath} audio ${this.audio.info.backend}`,
|
|
131
|
+
);
|
|
132
|
+
return true;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
await this.releaseLock?.();
|
|
135
|
+
this.releaseLock = undefined;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
public get socket(): string {
|
|
141
|
+
return this.socketPath;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Declared before `stopped` so field initialization order keeps the resolver.
|
|
145
|
+
private resolveStopped: () => void = () => undefined;
|
|
146
|
+
/** Resolves once the daemon has shut down for any reason. */
|
|
147
|
+
public readonly stopped: Promise<void> = new Promise((resolve) => {
|
|
148
|
+
this.resolveStopped = resolve;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
public stop(reason = "stop"): Promise<void> {
|
|
152
|
+
this.stopping ??= this.shutdown(reason).finally(() =>
|
|
153
|
+
this.resolveStopped(),
|
|
154
|
+
);
|
|
155
|
+
return this.stopping;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async shutdown(reason: string): Promise<void> {
|
|
159
|
+
if (this.graceTimer) clearTimeout(this.graceTimer);
|
|
160
|
+
if (this.diskTimer) clearInterval(this.diskTimer);
|
|
161
|
+
await this.audio.stopAsync();
|
|
162
|
+
for (const client of this.clients) client.socket.destroy();
|
|
163
|
+
this.clients.clear();
|
|
164
|
+
await this.queue.catch(() => undefined);
|
|
165
|
+
// Bun's server.close callback can wait on already-destroyed sockets, so
|
|
166
|
+
// bound it: the socket file is removed below either way.
|
|
167
|
+
await new Promise<void>((resolve) => {
|
|
168
|
+
if (!this.server) return resolve();
|
|
169
|
+
const timer = setTimeout(resolve, 250);
|
|
170
|
+
this.server.close(() => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
resolve();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
await rm(this.socketPath, { force: true });
|
|
176
|
+
await this.log(`stopped (${reason})`);
|
|
177
|
+
await this.releaseLock?.();
|
|
178
|
+
this.releaseLock = undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private accept(socket: Socket): void {
|
|
182
|
+
if (this.stopping) return void socket.destroy();
|
|
183
|
+
const client: Client = {
|
|
184
|
+
socket,
|
|
185
|
+
decoder: new LineDecoder(MAX_CLIENT_FRAME_BYTES),
|
|
186
|
+
ready: false,
|
|
187
|
+
};
|
|
188
|
+
this.clients.add(client);
|
|
189
|
+
if (this.graceTimer) clearTimeout(this.graceTimer);
|
|
190
|
+
this.graceTimer = undefined;
|
|
191
|
+
socket.setEncoding("utf8");
|
|
192
|
+
socket.on("data", (chunk: string) => {
|
|
193
|
+
let lines: string[];
|
|
194
|
+
try {
|
|
195
|
+
lines = client.decoder.push(chunk);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
this.fail(client, error);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
if (line.length === 0) continue;
|
|
202
|
+
let message: ClientMessage;
|
|
203
|
+
try {
|
|
204
|
+
message = parseClientMessage(line);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
this.fail(client, error);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
this.handle(client, message);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
// Bun does not always emit "close" for a socket that was written to
|
|
213
|
+
// after its peer hung up, so any of these ends the client exactly once.
|
|
214
|
+
const drop = () => {
|
|
215
|
+
if (!this.clients.delete(client)) return;
|
|
216
|
+
socket.destroy();
|
|
217
|
+
if (client.presence) this.broadcastPresence();
|
|
218
|
+
if (this.clients.size === 0) this.armGrace();
|
|
219
|
+
};
|
|
220
|
+
socket.on("end", drop);
|
|
221
|
+
socket.on("error", drop);
|
|
222
|
+
socket.on("close", drop);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private fail(client: Client, error: unknown): void {
|
|
226
|
+
const code = error instanceof ProtocolError ? error.code : "invalid";
|
|
227
|
+
const message = error instanceof Error ? error.message : "invalid frame";
|
|
228
|
+
this.send(client, { v: 1, type: "error", code, message });
|
|
229
|
+
client.socket.end();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private handle(client: Client, message: ClientMessage): void {
|
|
233
|
+
if (message.type === "hello") {
|
|
234
|
+
client.ready = true;
|
|
235
|
+
client.presence = {
|
|
236
|
+
clientId: message.clientId,
|
|
237
|
+
pid: message.pid,
|
|
238
|
+
label: message.label,
|
|
239
|
+
focusedTrackId: message.focusedTrackId,
|
|
240
|
+
};
|
|
241
|
+
this.send(client, {
|
|
242
|
+
v: 1,
|
|
243
|
+
type: "welcome",
|
|
244
|
+
sessionId: this.record.sessionId,
|
|
245
|
+
pid: process.pid,
|
|
246
|
+
record: this.record,
|
|
247
|
+
digest: this.digest,
|
|
248
|
+
transport: this.transportState(),
|
|
249
|
+
});
|
|
250
|
+
this.broadcastPresence();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (!client.ready) {
|
|
254
|
+
this.fail(client, new ProtocolError("handshake", "send hello first"));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (message.type === "focus") {
|
|
258
|
+
client.presence!.focusedTrackId = message.trackId;
|
|
259
|
+
this.broadcastPresence();
|
|
260
|
+
this.send(client, {
|
|
261
|
+
v: 1,
|
|
262
|
+
type: "claimed",
|
|
263
|
+
id: message.id,
|
|
264
|
+
trackId: message.trackId,
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (message.type === "claim") {
|
|
269
|
+
// Synchronous on the single event loop, so two simultaneous claims are
|
|
270
|
+
// serialized and can never pick the same track.
|
|
271
|
+
const taken = new Set<string>();
|
|
272
|
+
for (const other of this.clients)
|
|
273
|
+
if (other !== client && other.presence?.focusedTrackId)
|
|
274
|
+
taken.add(other.presence.focusedTrackId);
|
|
275
|
+
const trackIds = this.score.tracks.map((track) => track.id);
|
|
276
|
+
const claim = message.draft
|
|
277
|
+
? claimOrDraft(trackIds, taken, message.preferred)
|
|
278
|
+
: {
|
|
279
|
+
trackId: chooseTrack(trackIds, taken, message.preferred),
|
|
280
|
+
draft: false,
|
|
281
|
+
};
|
|
282
|
+
if (claim.trackId !== null)
|
|
283
|
+
client.presence!.focusedTrackId = claim.trackId;
|
|
284
|
+
const reply: ServerMessage = {
|
|
285
|
+
v: 1,
|
|
286
|
+
type: "claimed",
|
|
287
|
+
id: message.id,
|
|
288
|
+
trackId: claim.trackId,
|
|
289
|
+
};
|
|
290
|
+
if (claim.draft) reply.draft = true;
|
|
291
|
+
this.send(client, reply);
|
|
292
|
+
this.broadcastPresence();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (message.type === "ping") {
|
|
296
|
+
this.send(client, { v: 1, type: "pong", id: message.id });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (message.type === "sync") {
|
|
300
|
+
// Queue behind pending writes so the snapshot reflects them.
|
|
301
|
+
this.enqueue(async () => {
|
|
302
|
+
this.send(client, {
|
|
303
|
+
v: 1,
|
|
304
|
+
type: "snapshot",
|
|
305
|
+
record: this.record,
|
|
306
|
+
digest: this.digest,
|
|
307
|
+
});
|
|
308
|
+
this.send(client, {
|
|
309
|
+
v: 1,
|
|
310
|
+
type: "result",
|
|
311
|
+
id: message.id,
|
|
312
|
+
status: "accepted",
|
|
313
|
+
revision: this.record.revision,
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (message.type === "meta") {
|
|
319
|
+
// Queued with score writes so a rename never interleaves an append.
|
|
320
|
+
this.enqueue(async () => {
|
|
321
|
+
const result = await updateSessionMeta<Composition>(
|
|
322
|
+
this.paths,
|
|
323
|
+
message.patch,
|
|
324
|
+
message.expect,
|
|
325
|
+
);
|
|
326
|
+
if (result.record.revision !== this.record.revision) {
|
|
327
|
+
// A file-fallback window wrote the score too; adopt everything.
|
|
328
|
+
const previousTempo = this.score.tempoBpm;
|
|
329
|
+
await this.reload();
|
|
330
|
+
this.broadcast({
|
|
331
|
+
v: 1,
|
|
332
|
+
type: "snapshot",
|
|
333
|
+
record: this.record,
|
|
334
|
+
digest: this.digest,
|
|
335
|
+
});
|
|
336
|
+
this.afterScoreChange(previousTempo);
|
|
337
|
+
} else {
|
|
338
|
+
this.record = { ...this.record, meta: result.record.meta };
|
|
339
|
+
this.diskMtime = await this.recordMtime();
|
|
340
|
+
}
|
|
341
|
+
// Meta goes out before the result so the requester sees it first.
|
|
342
|
+
if (result.status === "applied")
|
|
343
|
+
this.broadcast({ v: 1, type: "meta", meta: this.record.meta });
|
|
344
|
+
else this.send(client, { v: 1, type: "meta", meta: this.record.meta });
|
|
345
|
+
this.send(
|
|
346
|
+
client,
|
|
347
|
+
result.status === "applied"
|
|
348
|
+
? {
|
|
349
|
+
v: 1,
|
|
350
|
+
type: "result",
|
|
351
|
+
id: message.id,
|
|
352
|
+
status: "accepted",
|
|
353
|
+
revision: this.record.revision,
|
|
354
|
+
}
|
|
355
|
+
: {
|
|
356
|
+
v: 1,
|
|
357
|
+
type: "result",
|
|
358
|
+
id: message.id,
|
|
359
|
+
status: "rejected",
|
|
360
|
+
code: "stale-meta",
|
|
361
|
+
message: "session name changed since the request began",
|
|
362
|
+
},
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (message.type === "transport") {
|
|
368
|
+
this.enqueue(async () => {
|
|
369
|
+
this.transport(message.action, message.beat, message.bpm);
|
|
370
|
+
this.send(client, {
|
|
371
|
+
v: 1,
|
|
372
|
+
type: "result",
|
|
373
|
+
id: message.id,
|
|
374
|
+
status: "accepted",
|
|
375
|
+
revision: this.record.revision,
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
this.enqueue(async () => {
|
|
381
|
+
const result = await this.apply(message);
|
|
382
|
+
this.send(client, { v: 1, type: "result", id: message.id, ...result });
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private enqueue(task: () => Promise<void>): void {
|
|
387
|
+
// One writer, strictly ordered: every intent observes the previous one.
|
|
388
|
+
this.queue = this.queue.then(task).catch(async (error: unknown) => {
|
|
389
|
+
await this.log(`task failed: ${String(error)}`);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private async apply(
|
|
394
|
+
message: Extract<ClientMessage, { type: "apply" }>,
|
|
395
|
+
): Promise<ApplyResult> {
|
|
396
|
+
const seen = this.keys.get(message.key);
|
|
397
|
+
if (seen !== undefined) return { status: "duplicate", revision: seen };
|
|
398
|
+
const stale = message.base !== this.record.revision;
|
|
399
|
+
let rebased = false;
|
|
400
|
+
let next: TrackScore;
|
|
401
|
+
try {
|
|
402
|
+
if (message.operations) {
|
|
403
|
+
const operations = message.operations.map(parseOperation);
|
|
404
|
+
if (stale) {
|
|
405
|
+
// Operation intents from an older base replay on the current score
|
|
406
|
+
// when nothing they touch changed in between.
|
|
407
|
+
const base = this.baseScore(message.base);
|
|
408
|
+
if (base === undefined)
|
|
409
|
+
return this.rebaseReply(message.base, "base is not recoverable");
|
|
410
|
+
const result = rebaseOperations(base, this.score, operations);
|
|
411
|
+
if (!result.ok) return this.rebaseReply(message.base, result.reason);
|
|
412
|
+
next = result.next;
|
|
413
|
+
rebased = true;
|
|
414
|
+
} else {
|
|
415
|
+
next = this.score;
|
|
416
|
+
for (const operation of operations)
|
|
417
|
+
next = applyScoreOperation(next, operation);
|
|
418
|
+
}
|
|
419
|
+
} else if (stale) return this.rebaseReply(message.base);
|
|
420
|
+
else next = scoreFromJSON(message.composition);
|
|
421
|
+
// Round-trip through the parser so only canonical score data is stored.
|
|
422
|
+
next = scoreFromJSON(next.toJSON());
|
|
423
|
+
} catch (error) {
|
|
424
|
+
return {
|
|
425
|
+
status: "rejected",
|
|
426
|
+
code: "invalid-operation",
|
|
427
|
+
message: error instanceof Error ? error.message : String(error),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
// Every event records the score it replaced (`before`) so undo and later
|
|
431
|
+
// rebases can recover it; after a rebase that is the score replayed on.
|
|
432
|
+
const payload =
|
|
433
|
+
typeof message.payload === "object" &&
|
|
434
|
+
message.payload !== null &&
|
|
435
|
+
!Array.isArray(message.payload)
|
|
436
|
+
? {
|
|
437
|
+
...(message.payload as Record<string, unknown>),
|
|
438
|
+
...(rebased || !("before" in message.payload)
|
|
439
|
+
? { before: this.record.composition }
|
|
440
|
+
: {}),
|
|
441
|
+
...(rebased ? { rebasedFrom: message.base } : {}),
|
|
442
|
+
}
|
|
443
|
+
: message.payload;
|
|
444
|
+
const previousTempo = this.score.tempoBpm;
|
|
445
|
+
try {
|
|
446
|
+
this.record = await appendSessionEvent(
|
|
447
|
+
this.paths,
|
|
448
|
+
this.record,
|
|
449
|
+
{ kind: message.kind, payload, id: message.key },
|
|
450
|
+
next.toJSON(),
|
|
451
|
+
);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
if (error instanceof SessionConflictError) {
|
|
454
|
+
// A file-fallback window wrote directly; adopt the disk state.
|
|
455
|
+
await this.reload();
|
|
456
|
+
this.broadcast({
|
|
457
|
+
v: 1,
|
|
458
|
+
type: "snapshot",
|
|
459
|
+
record: this.record,
|
|
460
|
+
digest: this.digest,
|
|
461
|
+
});
|
|
462
|
+
return {
|
|
463
|
+
status: "rebase",
|
|
464
|
+
baseRevision: message.base,
|
|
465
|
+
currentRevision: this.record.revision,
|
|
466
|
+
message: "session changed on disk; rebase",
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
status: "rejected",
|
|
471
|
+
code: "store",
|
|
472
|
+
message: error instanceof Error ? error.message : String(error),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
this.score = next;
|
|
476
|
+
this.digest = compositionDigest(this.record.composition);
|
|
477
|
+
this.keys.set(message.key, this.record.revision);
|
|
478
|
+
this.diskMtime = await this.recordMtime();
|
|
479
|
+
const event = this.record.events[this.record.events.length - 1]!;
|
|
480
|
+
this.broadcast({
|
|
481
|
+
v: 1,
|
|
482
|
+
type: "commit",
|
|
483
|
+
event,
|
|
484
|
+
composition: this.record.composition,
|
|
485
|
+
digest: this.digest,
|
|
486
|
+
});
|
|
487
|
+
this.afterScoreChange(previousTempo);
|
|
488
|
+
return { status: "accepted", revision: this.record.revision };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private rebaseReply(base: number, reason?: string): ApplyResult {
|
|
492
|
+
return {
|
|
493
|
+
status: "rebase",
|
|
494
|
+
baseRevision: base,
|
|
495
|
+
currentRevision: this.record.revision,
|
|
496
|
+
message: `session is at rev ${this.record.revision}; rebase from rev ${base}${reason ? ` (${reason})` : ""}`,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The score at an older revision, when the log still vouches for it. */
|
|
501
|
+
private baseScore(revision: number): TrackScore | undefined {
|
|
502
|
+
if (
|
|
503
|
+
!Number.isSafeInteger(revision) ||
|
|
504
|
+
revision < 0 ||
|
|
505
|
+
revision >= this.record.revision ||
|
|
506
|
+
this.record.revision - revision > MAX_REBASE_DISTANCE
|
|
507
|
+
)
|
|
508
|
+
return undefined;
|
|
509
|
+
const composition = compositionAt(this.record.events, revision);
|
|
510
|
+
if (composition === undefined) return undefined;
|
|
511
|
+
try {
|
|
512
|
+
return scoreFromJSON(composition);
|
|
513
|
+
} catch {
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private afterScoreChange(previousTempo: number): void {
|
|
519
|
+
if (this.score.tempoBpm !== previousTempo) {
|
|
520
|
+
this.clock.setTempo(this.score.tempoBpm);
|
|
521
|
+
this.broadcastTransport();
|
|
522
|
+
}
|
|
523
|
+
// Gapless: a streaming engine swaps the loop at the current beat
|
|
524
|
+
// without restarting the player.
|
|
525
|
+
if (this.clock.playing)
|
|
526
|
+
void this.audio.play(this.score, this.clock.beatAt()).catch(() => {});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private transport(
|
|
530
|
+
action: "play" | "pause" | "toggle" | "seek" | "tempo",
|
|
531
|
+
beat?: number,
|
|
532
|
+
bpm?: number,
|
|
533
|
+
): void {
|
|
534
|
+
const play =
|
|
535
|
+
action === "play" || (action === "toggle" && !this.clock.playing);
|
|
536
|
+
const pause =
|
|
537
|
+
action === "pause" || (action === "toggle" && this.clock.playing);
|
|
538
|
+
if (play && !this.clock.playing) {
|
|
539
|
+
this.clock.play();
|
|
540
|
+
void this.audio.play(this.score, this.clock.beatAt()).catch(() => {});
|
|
541
|
+
} else if (pause && this.clock.playing) {
|
|
542
|
+
this.clock.pause();
|
|
543
|
+
this.audio.stop();
|
|
544
|
+
} else if (action === "seek" && beat !== undefined) {
|
|
545
|
+
const now = Date.now();
|
|
546
|
+
this.clock.sync(beat, this.clock.playing, now, now);
|
|
547
|
+
this.audio.seek(this.clock.beatAt());
|
|
548
|
+
} else if (action === "tempo" && bpm !== undefined) {
|
|
549
|
+
this.clock.setTempo(bpm);
|
|
550
|
+
}
|
|
551
|
+
this.broadcastTransport();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private transportState(): TransportState {
|
|
555
|
+
const atMs = monotonicEpochMs();
|
|
556
|
+
return {
|
|
557
|
+
seq: this.seq,
|
|
558
|
+
playing: this.clock.playing,
|
|
559
|
+
beat: this.clock.beatAt(),
|
|
560
|
+
bpm: this.score.tempoBpm,
|
|
561
|
+
atMs,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private broadcastTransport(): void {
|
|
566
|
+
this.seq += 1;
|
|
567
|
+
this.broadcast({ v: 1, type: "transport", ...this.transportState() });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private presence(): PresenceEntry[] {
|
|
571
|
+
const entries: PresenceEntry[] = [];
|
|
572
|
+
for (const client of this.clients)
|
|
573
|
+
if (client.ready && client.presence) entries.push({ ...client.presence });
|
|
574
|
+
return entries;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private broadcastPresence(): void {
|
|
578
|
+
this.broadcast({ v: 1, type: "presence", clients: this.presence() });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private broadcast(message: ServerMessage): void {
|
|
582
|
+
const frame = encodeFrame(message);
|
|
583
|
+
for (const client of this.clients)
|
|
584
|
+
if (client.ready) this.write(client, frame);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private send(client: Client, message: ServerMessage): void {
|
|
588
|
+
this.write(client, encodeFrame(message));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private write(client: Client, frame: string): void {
|
|
592
|
+
if (client.socket.destroyed) return;
|
|
593
|
+
if (client.socket.writableLength > MAX_CLIENT_BACKLOG_BYTES) {
|
|
594
|
+
client.socket.destroy();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
client.socket.write(frame);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private async reload(): Promise<void> {
|
|
601
|
+
this.record = await loadSession<Composition>(this.paths);
|
|
602
|
+
this.score = scoreFromJSON(this.record.composition);
|
|
603
|
+
this.digest = compositionDigest(this.record.composition);
|
|
604
|
+
this.keys.clear();
|
|
605
|
+
for (const event of this.record.events)
|
|
606
|
+
this.keys.set(event.id, event.revision);
|
|
607
|
+
this.diskMtime = await this.recordMtime();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Adopt writes from windows that fell back to the file-lock path. */
|
|
611
|
+
private async pollDisk(): Promise<void> {
|
|
612
|
+
const mtime = await this.recordMtime();
|
|
613
|
+
if (mtime === this.diskMtime) return;
|
|
614
|
+
this.enqueue(async () => {
|
|
615
|
+
const latest = await loadSession<Composition>(this.paths).catch(
|
|
616
|
+
() => undefined,
|
|
617
|
+
);
|
|
618
|
+
this.diskMtime = await this.recordMtime();
|
|
619
|
+
if (!latest) return;
|
|
620
|
+
if (latest.revision <= this.record.revision) {
|
|
621
|
+
// A file-fallback rename changes only metadata.
|
|
622
|
+
if (
|
|
623
|
+
latest.revision === this.record.revision &&
|
|
624
|
+
latest.meta.version > this.record.meta.version
|
|
625
|
+
) {
|
|
626
|
+
this.record = { ...this.record, meta: latest.meta };
|
|
627
|
+
this.broadcast({ v: 1, type: "meta", meta: latest.meta });
|
|
628
|
+
}
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const previousTempo = this.score.tempoBpm;
|
|
632
|
+
await this.reload();
|
|
633
|
+
this.broadcast({
|
|
634
|
+
v: 1,
|
|
635
|
+
type: "snapshot",
|
|
636
|
+
record: this.record,
|
|
637
|
+
digest: this.digest,
|
|
638
|
+
});
|
|
639
|
+
this.afterScoreChange(previousTempo);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private async recordMtime(): Promise<number> {
|
|
644
|
+
try {
|
|
645
|
+
return (await stat(this.paths.record)).mtimeMs;
|
|
646
|
+
} catch {
|
|
647
|
+
return 0;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private armGrace(): void {
|
|
652
|
+
if (this.graceTimer) clearTimeout(this.graceTimer);
|
|
653
|
+
this.graceTimer = setTimeout(() => {
|
|
654
|
+
if (this.clients.size === 0) void this.stop("idle");
|
|
655
|
+
}, this.graceMs);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private async log(line: string): Promise<void> {
|
|
659
|
+
const path = daemonLogPath(this.paths);
|
|
660
|
+
try {
|
|
661
|
+
if ((await stat(path)).size > MAX_LOG_BYTES) await truncate(path, 0);
|
|
662
|
+
} catch {
|
|
663
|
+
// Missing log file is created by the append below.
|
|
664
|
+
}
|
|
665
|
+
await appendFile(path, `${new Date().toISOString()} ${line}\n`).catch(
|
|
666
|
+
() => undefined,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const OPERATION_TYPES = new Set([
|
|
672
|
+
"addTrack",
|
|
673
|
+
"addNote",
|
|
674
|
+
"removeNote",
|
|
675
|
+
"updateNote",
|
|
676
|
+
"setTempo",
|
|
677
|
+
"setBars",
|
|
678
|
+
"updateTrack",
|
|
679
|
+
"setAutomation",
|
|
680
|
+
"clearTrack",
|
|
681
|
+
]);
|
|
682
|
+
|
|
683
|
+
/** Shape gate before the reducer, which validates every field it reads. */
|
|
684
|
+
function parseOperation(value: unknown): ScoreOperation {
|
|
685
|
+
if (
|
|
686
|
+
typeof value !== "object" ||
|
|
687
|
+
value === null ||
|
|
688
|
+
Array.isArray(value) ||
|
|
689
|
+
typeof (value as { type?: unknown }).type !== "string" ||
|
|
690
|
+
!OPERATION_TYPES.has((value as { type: string }).type)
|
|
691
|
+
)
|
|
692
|
+
throw new Error("unsupported score operation");
|
|
693
|
+
return value as ScoreOperation;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Entry point used by `src/daemon.ts`. Exits the process when done. */
|
|
697
|
+
export async function runDaemon(options: DaemonOptions): Promise<never> {
|
|
698
|
+
const daemon = new DawgDaemon(options);
|
|
699
|
+
// Install signal handlers before listening: a client can connect, finish,
|
|
700
|
+
// and send SIGTERM before start() has returned.
|
|
701
|
+
let started = false;
|
|
702
|
+
let pendingSignal: string | undefined;
|
|
703
|
+
const exit = (signal: string) => {
|
|
704
|
+
if (!started) {
|
|
705
|
+
pendingSignal = signal;
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
void daemon.stop(signal).finally(() => process.exit(0));
|
|
709
|
+
};
|
|
710
|
+
process.on("SIGTERM", () => exit("SIGTERM"));
|
|
711
|
+
process.on("SIGINT", () => exit("SIGINT"));
|
|
712
|
+
process.on("SIGHUP", () => exit("SIGHUP"));
|
|
713
|
+
try {
|
|
714
|
+
started = await daemon.start();
|
|
715
|
+
} catch (error) {
|
|
716
|
+
process.stderr.write(`dawgd: ${String(error)}\n`);
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
if (!started) process.exit(0); // Another daemon owns the session.
|
|
720
|
+
if (pendingSignal) exit(pendingSignal);
|
|
721
|
+
await daemon.stopped;
|
|
722
|
+
process.exit(0);
|
|
723
|
+
}
|