@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,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side rebase of agent operation intents.
|
|
3
|
+
*
|
|
4
|
+
* An agent validates its operations against the score at `base`. When other
|
|
5
|
+
* windows committed in the meantime, dawgd replays the operations on the
|
|
6
|
+
* current score instead of rejecting, but only when nothing they touch
|
|
7
|
+
* changed since `base`: the notes they update or remove, the tracks whose
|
|
8
|
+
* settings or contents they rewrite, the tracks they add notes to, and the
|
|
9
|
+
* tempo or length they set. Anything else is a conflict and the caller gets
|
|
10
|
+
* the usual `rebase` reply. The reducer still validates every operation.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
applyScoreOperation,
|
|
14
|
+
scoreFromJSON,
|
|
15
|
+
type ScoreOperation,
|
|
16
|
+
type TrackScore,
|
|
17
|
+
} from "../../core/score.ts";
|
|
18
|
+
|
|
19
|
+
/** How far behind a base may be and still be rebased. */
|
|
20
|
+
export const MAX_REBASE_DISTANCE = 64;
|
|
21
|
+
|
|
22
|
+
export type RebaseResult =
|
|
23
|
+
{ ok: true; next: TrackScore } | { ok: false; reason: string };
|
|
24
|
+
|
|
25
|
+
export function rebaseOperations(
|
|
26
|
+
base: TrackScore,
|
|
27
|
+
current: TrackScore,
|
|
28
|
+
operations: readonly ScoreOperation[],
|
|
29
|
+
): RebaseResult {
|
|
30
|
+
const baseNotes = new Map(base.notes.map((note) => [note.id, note]));
|
|
31
|
+
const currentNotes = new Map(current.notes.map((note) => [note.id, note]));
|
|
32
|
+
const same = (a: unknown, b: unknown) =>
|
|
33
|
+
JSON.stringify(a) === JSON.stringify(b);
|
|
34
|
+
const trackSettings = (score: TrackScore, id: string) =>
|
|
35
|
+
score.tracks.find((track) => track.id === id);
|
|
36
|
+
const trackNotes = (score: TrackScore, id: string) =>
|
|
37
|
+
score.notes.filter((note) => note.trackId === id);
|
|
38
|
+
const settingsUnchanged = (id: string) =>
|
|
39
|
+
same(trackSettings(base, id), trackSettings(current, id));
|
|
40
|
+
const notesUnchanged = (id: string) =>
|
|
41
|
+
same(trackNotes(base, id), trackNotes(current, id));
|
|
42
|
+
// Tracks and notes the intent itself creates are not concurrent edits.
|
|
43
|
+
const added = { tracks: new Set<string>(), notes: new Set<string>() };
|
|
44
|
+
for (const operation of operations) {
|
|
45
|
+
switch (operation.type) {
|
|
46
|
+
case "addTrack": {
|
|
47
|
+
const id = operation.track.id;
|
|
48
|
+
if (typeof id === "string") {
|
|
49
|
+
if (trackSettings(current, id) && !trackSettings(base, id))
|
|
50
|
+
return { ok: false, reason: `track ${id} was added concurrently` };
|
|
51
|
+
added.tracks.add(id);
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case "addNote": {
|
|
56
|
+
const { id, trackId } = operation.note;
|
|
57
|
+
if (
|
|
58
|
+
typeof id === "string" &&
|
|
59
|
+
currentNotes.has(id) &&
|
|
60
|
+
!baseNotes.has(id)
|
|
61
|
+
)
|
|
62
|
+
return { ok: false, reason: `note ${id} was added concurrently` };
|
|
63
|
+
if (typeof id === "string") added.notes.add(id);
|
|
64
|
+
if (
|
|
65
|
+
typeof trackId === "string" &&
|
|
66
|
+
!added.tracks.has(trackId) &&
|
|
67
|
+
!trackSettings(current, trackId)
|
|
68
|
+
)
|
|
69
|
+
return { ok: false, reason: `track ${trackId} was removed` };
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case "removeNote":
|
|
73
|
+
case "updateNote": {
|
|
74
|
+
if (added.notes.has(operation.noteId)) break;
|
|
75
|
+
if (
|
|
76
|
+
!same(
|
|
77
|
+
baseNotes.get(operation.noteId),
|
|
78
|
+
currentNotes.get(operation.noteId),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return { ok: false, reason: `note ${operation.noteId} changed` };
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "updateTrack":
|
|
85
|
+
case "setAutomation": {
|
|
86
|
+
if (added.tracks.has(operation.trackId)) break;
|
|
87
|
+
if (!settingsUnchanged(operation.trackId))
|
|
88
|
+
return { ok: false, reason: `track ${operation.trackId} changed` };
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "clearTrack": {
|
|
92
|
+
if (added.tracks.has(operation.trackId)) break;
|
|
93
|
+
if (
|
|
94
|
+
!settingsUnchanged(operation.trackId) ||
|
|
95
|
+
!notesUnchanged(operation.trackId)
|
|
96
|
+
)
|
|
97
|
+
return { ok: false, reason: `track ${operation.trackId} changed` };
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "setTempo":
|
|
101
|
+
if (base.tempoBpm !== current.tempoBpm)
|
|
102
|
+
return { ok: false, reason: "tempo changed" };
|
|
103
|
+
break;
|
|
104
|
+
case "setBars":
|
|
105
|
+
if (base.bars !== current.bars)
|
|
106
|
+
return { ok: false, reason: "loop length changed" };
|
|
107
|
+
break;
|
|
108
|
+
default:
|
|
109
|
+
return { ok: false, reason: "unsupported operation" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
let next = current;
|
|
114
|
+
for (const operation of operations)
|
|
115
|
+
next = applyScoreOperation(next, operation);
|
|
116
|
+
return { ok: true, next: scoreFromJSON(next.toJSON()) };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The composition at revision `revision`, recovered from the `before` of the
|
|
127
|
+
* event that replaced it. Undefined when the log cannot vouch for it.
|
|
128
|
+
*/
|
|
129
|
+
export function compositionAt(
|
|
130
|
+
events: readonly { revision: number; payload: unknown }[],
|
|
131
|
+
revision: number,
|
|
132
|
+
): unknown {
|
|
133
|
+
const event = events[revision];
|
|
134
|
+
if (event === undefined || event.revision !== revision + 1) return undefined;
|
|
135
|
+
const payload = event.payload;
|
|
136
|
+
if (typeof payload !== "object" || payload === null) return undefined;
|
|
137
|
+
return (payload as { before?: unknown }).before;
|
|
138
|
+
}
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { acquireSessionLock } from "./lock.ts";
|
|
5
|
+
import {
|
|
6
|
+
applyMetaPatch,
|
|
7
|
+
defaultSessionMeta,
|
|
8
|
+
metaMatches,
|
|
9
|
+
MetaValidationError,
|
|
10
|
+
normalizeSessionName,
|
|
11
|
+
parseSessionMeta,
|
|
12
|
+
type MetaExpect,
|
|
13
|
+
type MetaPatch,
|
|
14
|
+
type SessionMeta,
|
|
15
|
+
} from "./meta.ts";
|
|
16
|
+
|
|
17
|
+
const MAX_EVENT_BYTES = 64 * 1024;
|
|
18
|
+
const MAX_EVENTS = 2_000;
|
|
19
|
+
const MAX_RECORD_BYTES = 4 * 1024 * 1024;
|
|
20
|
+
const MAX_SESSION_ID_LENGTH = 64;
|
|
21
|
+
const MAX_EVENT_KIND_LENGTH = 128;
|
|
22
|
+
const MAX_TIMESTAMP_LENGTH = 64;
|
|
23
|
+
|
|
24
|
+
export class SessionValidationError extends Error {
|
|
25
|
+
public constructor(message: string) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "SessionValidationError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SessionEvent = {
|
|
32
|
+
id: string;
|
|
33
|
+
revision: number;
|
|
34
|
+
kind: string;
|
|
35
|
+
payload: unknown;
|
|
36
|
+
at: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type SessionRecord<T> = {
|
|
40
|
+
sessionId: string;
|
|
41
|
+
revision: number;
|
|
42
|
+
updatedAt: string;
|
|
43
|
+
composition: T;
|
|
44
|
+
events: SessionEvent[];
|
|
45
|
+
/** Name and lineage. Versioned separately so renames keep the revision. */
|
|
46
|
+
meta: SessionMeta;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type SessionPaths = {
|
|
50
|
+
root: string;
|
|
51
|
+
pointer: string;
|
|
52
|
+
record: string;
|
|
53
|
+
lock: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export class SessionConflictError extends Error {
|
|
57
|
+
public constructor() {
|
|
58
|
+
super(
|
|
59
|
+
"session changed in another dawg window; retry against the latest revision",
|
|
60
|
+
);
|
|
61
|
+
this.name = "SessionConflictError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Per-workspace state directory. */
|
|
66
|
+
export const STATE_DIR = ".dawg";
|
|
67
|
+
|
|
68
|
+
/** The workspace state directory, `<workspace>/.dawg`. */
|
|
69
|
+
export function stateDir(workspace = process.cwd()): string {
|
|
70
|
+
return join(workspace, STATE_DIR);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sessionPaths(
|
|
74
|
+
workspace = process.cwd(),
|
|
75
|
+
sessionId: string,
|
|
76
|
+
): SessionPaths {
|
|
77
|
+
assertSessionId(sessionId);
|
|
78
|
+
const root = stateDir(workspace);
|
|
79
|
+
return {
|
|
80
|
+
root,
|
|
81
|
+
pointer: join(root, "session"),
|
|
82
|
+
record: join(root, "sessions", `${sessionId}.json`),
|
|
83
|
+
lock: join(root, "sessions", `${sessionId}.lock`),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function readCurrentSessionId(
|
|
88
|
+
workspace = process.cwd(),
|
|
89
|
+
): Promise<string | undefined> {
|
|
90
|
+
try {
|
|
91
|
+
const id = (
|
|
92
|
+
await readFile(join(stateDir(workspace), "session"), "utf8")
|
|
93
|
+
).trim();
|
|
94
|
+
if (id.length === 0) return undefined;
|
|
95
|
+
assertSessionId(id);
|
|
96
|
+
return id;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (isCode(error, "ENOENT")) return undefined;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function ensureSession<T>(
|
|
104
|
+
initial: T,
|
|
105
|
+
options: {
|
|
106
|
+
workspace?: string;
|
|
107
|
+
sessionId?: string;
|
|
108
|
+
setCurrent?: boolean;
|
|
109
|
+
/** Name for a newly created session; ignored when it already exists. */
|
|
110
|
+
name?: string;
|
|
111
|
+
} = {},
|
|
112
|
+
): Promise<{ paths: SessionPaths; record: SessionRecord<T> }> {
|
|
113
|
+
const workspace = options.workspace ?? process.cwd();
|
|
114
|
+
const root = stateDir(workspace);
|
|
115
|
+
await mkdir(root, { recursive: true });
|
|
116
|
+
// Serialize pointer selection and first-record creation. Without this,
|
|
117
|
+
// two windows launched together can each choose a different random session
|
|
118
|
+
// and race to overwrite `.dawg/session`.
|
|
119
|
+
const initLock = join(root, ".init.lock");
|
|
120
|
+
const release = await acquireSessionLock(initLock);
|
|
121
|
+
try {
|
|
122
|
+
const sessionId =
|
|
123
|
+
options.sessionId ??
|
|
124
|
+
(await readCurrentSessionId(workspace)) ??
|
|
125
|
+
randomUUID();
|
|
126
|
+
const paths = sessionPaths(workspace, sessionId);
|
|
127
|
+
await mkdir(dirname(paths.record), { recursive: true });
|
|
128
|
+
const existing = await readRecordIfPresent<T>(paths.record);
|
|
129
|
+
if (existing) {
|
|
130
|
+
if (options.setCurrent !== false)
|
|
131
|
+
await writeAtomic(paths.pointer, `${sessionId}`);
|
|
132
|
+
return { paths, record: existing };
|
|
133
|
+
}
|
|
134
|
+
if (options.setCurrent !== false)
|
|
135
|
+
await writeAtomic(paths.pointer, `${sessionId}`);
|
|
136
|
+
try {
|
|
137
|
+
const at = new Date().toISOString();
|
|
138
|
+
const record: SessionRecord<T> = {
|
|
139
|
+
sessionId,
|
|
140
|
+
revision: 0,
|
|
141
|
+
updatedAt: at,
|
|
142
|
+
composition: initial,
|
|
143
|
+
events: [],
|
|
144
|
+
meta: defaultSessionMeta(
|
|
145
|
+
at,
|
|
146
|
+
options.name === undefined
|
|
147
|
+
? undefined
|
|
148
|
+
: normalizeSessionName(options.name),
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
const validated = validateSessionRecord<T>(record);
|
|
152
|
+
await writeAtomic(paths.record, JSON.stringify(validated, null, 2));
|
|
153
|
+
return { paths, record: validated };
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (!isCode(error, "ENOENT")) throw error;
|
|
156
|
+
// A concurrent external creator can win only if it does not use the
|
|
157
|
+
// init lock. Read it and validate rather than replacing its record.
|
|
158
|
+
const record = await readRecordIfPresent<T>(paths.record);
|
|
159
|
+
if (!record) throw error;
|
|
160
|
+
return { paths, record };
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
await release();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function appendSessionEvent<T>(
|
|
168
|
+
paths: SessionPaths,
|
|
169
|
+
current: SessionRecord<T>,
|
|
170
|
+
event: Omit<SessionEvent, "id" | "revision" | "at"> & { id?: string },
|
|
171
|
+
composition: T,
|
|
172
|
+
): Promise<SessionRecord<T>> {
|
|
173
|
+
// dawgd passes the client's idempotency key as the durable event id so a
|
|
174
|
+
// retried intent stays a no-op across daemon restarts.
|
|
175
|
+
if (event.id !== undefined) assertSessionId(event.id);
|
|
176
|
+
const payloadJson = stringifyJson(event.payload, "session event payload");
|
|
177
|
+
const payloadBytes = Buffer.byteLength(payloadJson, "utf8");
|
|
178
|
+
if (payloadBytes > MAX_EVENT_BYTES)
|
|
179
|
+
throw new Error(`session event exceeds ${MAX_EVENT_BYTES} bytes`);
|
|
180
|
+
if (
|
|
181
|
+
typeof event.kind !== "string" ||
|
|
182
|
+
event.kind.length === 0 ||
|
|
183
|
+
event.kind.length > MAX_EVENT_KIND_LENGTH
|
|
184
|
+
)
|
|
185
|
+
throw new SessionValidationError("session event kind is invalid");
|
|
186
|
+
const release = await acquireSessionLock(paths.lock);
|
|
187
|
+
try {
|
|
188
|
+
const disk = await readRecord<T>(paths.record);
|
|
189
|
+
if (disk.revision !== current.revision) throw new SessionConflictError();
|
|
190
|
+
// Re-check the bounded event budget against the locked record. A stale
|
|
191
|
+
// caller can pass the pre-lock check while another writer fills the log.
|
|
192
|
+
if (disk.events.length >= MAX_EVENTS)
|
|
193
|
+
throw new Error(`session event limit ${MAX_EVENTS} reached`);
|
|
194
|
+
const revision = disk.revision + 1;
|
|
195
|
+
const at = new Date().toISOString();
|
|
196
|
+
const next: SessionRecord<T> = {
|
|
197
|
+
sessionId: disk.sessionId,
|
|
198
|
+
revision,
|
|
199
|
+
updatedAt: at,
|
|
200
|
+
composition,
|
|
201
|
+
events: [
|
|
202
|
+
...disk.events,
|
|
203
|
+
{
|
|
204
|
+
kind: event.kind,
|
|
205
|
+
payload: event.payload,
|
|
206
|
+
id: event.id ?? randomUUID(),
|
|
207
|
+
revision,
|
|
208
|
+
at,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
// Metadata always comes from disk so a rename written by another window
|
|
212
|
+
// between this caller's read and its write is never reverted.
|
|
213
|
+
meta: disk.meta,
|
|
214
|
+
};
|
|
215
|
+
const validated = validateSessionRecord<T>(next);
|
|
216
|
+
await writeAtomic(paths.record, JSON.stringify(validated, null, 2));
|
|
217
|
+
return validated;
|
|
218
|
+
} finally {
|
|
219
|
+
await release();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export type MetaUpdate<T> =
|
|
224
|
+
| { status: "applied"; record: SessionRecord<T> }
|
|
225
|
+
| { status: "stale"; record: SessionRecord<T> };
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Conditional metadata write under the session lock. It applies only when
|
|
229
|
+
* `expect` still matches the record on disk, so the latest user rename wins
|
|
230
|
+
* and an in-flight auto-name computed against an older name is dropped.
|
|
231
|
+
* The score revision and event log are untouched.
|
|
232
|
+
*/
|
|
233
|
+
export async function updateSessionMeta<T>(
|
|
234
|
+
paths: SessionPaths,
|
|
235
|
+
patch: MetaPatch,
|
|
236
|
+
expect?: MetaExpect,
|
|
237
|
+
): Promise<MetaUpdate<T>> {
|
|
238
|
+
const release = await acquireSessionLock(paths.lock);
|
|
239
|
+
try {
|
|
240
|
+
const disk = await readRecord<T>(paths.record);
|
|
241
|
+
if (!metaMatches(disk.meta, expect))
|
|
242
|
+
return { status: "stale", record: disk };
|
|
243
|
+
const next: SessionRecord<T> = {
|
|
244
|
+
...disk,
|
|
245
|
+
meta: applyMetaPatch(disk.meta, patch, new Date().toISOString()),
|
|
246
|
+
};
|
|
247
|
+
const validated = validateSessionRecord<T>(next);
|
|
248
|
+
await writeAtomic(paths.record, JSON.stringify(validated, null, 2));
|
|
249
|
+
return { status: "applied", record: validated };
|
|
250
|
+
} finally {
|
|
251
|
+
await release();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Creates a new session holding a snapshot of `source` at its current
|
|
257
|
+
* revision: the composition only, not the event log, with `forkOf` lineage.
|
|
258
|
+
*/
|
|
259
|
+
export async function createForkSession<T>(
|
|
260
|
+
workspace: string,
|
|
261
|
+
source: SessionRecord<T>,
|
|
262
|
+
name: string,
|
|
263
|
+
): Promise<SessionRecord<T>> {
|
|
264
|
+
const sessionId = randomUUID();
|
|
265
|
+
const paths = sessionPaths(workspace, sessionId);
|
|
266
|
+
await mkdir(dirname(paths.record), { recursive: true });
|
|
267
|
+
const at = new Date().toISOString();
|
|
268
|
+
const record = validateSessionRecord<T>({
|
|
269
|
+
sessionId,
|
|
270
|
+
revision: 0,
|
|
271
|
+
updatedAt: at,
|
|
272
|
+
composition: source.composition,
|
|
273
|
+
events: [],
|
|
274
|
+
meta: {
|
|
275
|
+
...defaultSessionMeta(at, normalizeSessionName(name)),
|
|
276
|
+
nameSource: source.meta.nameSource,
|
|
277
|
+
forkOf: { sessionId: source.sessionId, revision: source.revision },
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
await writeAtomic(paths.record, JSON.stringify(record, null, 2));
|
|
281
|
+
return record;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Fork ancestry walked for undo, and the inherited event budget. */
|
|
285
|
+
export const MAX_FORK_DEPTH = 8;
|
|
286
|
+
export const MAX_INHERITED_EVENTS = MAX_EVENTS;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The event log a fork inherits for undo: each ancestor's events up to the
|
|
290
|
+
* revision it was forked at, oldest ancestor first, via the `forkOf` chain.
|
|
291
|
+
* Nothing is copied at fork time; the parent prefix is append-only, so it is
|
|
292
|
+
* immutable and safe to read later. Bounded by depth, by total events (the
|
|
293
|
+
* oldest are dropped first) and by cycle detection. A missing, unreadable or
|
|
294
|
+
* inconsistent ancestor (forked past its own revision) ends the chain there.
|
|
295
|
+
*/
|
|
296
|
+
export async function inheritedEvents(
|
|
297
|
+
workspace: string,
|
|
298
|
+
meta: SessionMeta,
|
|
299
|
+
selfId: string,
|
|
300
|
+
): Promise<SessionEvent[]> {
|
|
301
|
+
const chunks: SessionEvent[][] = [];
|
|
302
|
+
const seen = new Set([selfId]);
|
|
303
|
+
let fork = meta.forkOf;
|
|
304
|
+
let total = 0;
|
|
305
|
+
for (
|
|
306
|
+
let depth = 0;
|
|
307
|
+
fork && depth < MAX_FORK_DEPTH && total < MAX_INHERITED_EVENTS;
|
|
308
|
+
depth += 1
|
|
309
|
+
) {
|
|
310
|
+
if (seen.has(fork.sessionId)) break;
|
|
311
|
+
seen.add(fork.sessionId);
|
|
312
|
+
let parent: SessionRecord<unknown> | undefined;
|
|
313
|
+
try {
|
|
314
|
+
parent = await readRecordIfPresent<unknown>(
|
|
315
|
+
sessionPaths(workspace, fork.sessionId).record,
|
|
316
|
+
);
|
|
317
|
+
} catch {
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
if (!parent || fork.revision > parent.events.length) break;
|
|
321
|
+
const prefix = parent.events.slice(0, fork.revision);
|
|
322
|
+
chunks.unshift(prefix.slice(-(MAX_INHERITED_EVENTS - total)));
|
|
323
|
+
total += prefix.length;
|
|
324
|
+
fork = parent.meta.forkOf;
|
|
325
|
+
}
|
|
326
|
+
return chunks.flat().slice(-MAX_INHERITED_EVENTS);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Points `.dawg/session` at `sessionId` so plain `dawg` resumes it. */
|
|
330
|
+
export async function setCurrentSession(
|
|
331
|
+
workspace: string,
|
|
332
|
+
sessionId: string,
|
|
333
|
+
): Promise<void> {
|
|
334
|
+
const paths = sessionPaths(workspace, sessionId);
|
|
335
|
+
await mkdir(paths.root, { recursive: true });
|
|
336
|
+
await writeAtomic(paths.pointer, sessionId);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function loadSession<T>(
|
|
340
|
+
paths: SessionPaths,
|
|
341
|
+
): Promise<SessionRecord<T>> {
|
|
342
|
+
return readRecord<T>(paths.record);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function writeAtomic(path: string, contents: string): Promise<void> {
|
|
346
|
+
if (Buffer.byteLength(contents, "utf8") > MAX_RECORD_BYTES)
|
|
347
|
+
throw new SessionValidationError(
|
|
348
|
+
`session record exceeds ${MAX_RECORD_BYTES} bytes`,
|
|
349
|
+
);
|
|
350
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
351
|
+
await writeFile(temporary, `${contents}\n`, "utf8");
|
|
352
|
+
await rename(temporary, path);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function readRecord<T>(path: string): Promise<SessionRecord<T>> {
|
|
356
|
+
const contents = await readFile(path, "utf8");
|
|
357
|
+
if (Buffer.byteLength(contents, "utf8") > MAX_RECORD_BYTES)
|
|
358
|
+
throw new SessionValidationError(
|
|
359
|
+
`session record exceeds ${MAX_RECORD_BYTES} bytes`,
|
|
360
|
+
);
|
|
361
|
+
let value: unknown;
|
|
362
|
+
try {
|
|
363
|
+
value = JSON.parse(contents);
|
|
364
|
+
} catch {
|
|
365
|
+
throw new SessionValidationError("session record contains invalid JSON");
|
|
366
|
+
}
|
|
367
|
+
return validateSessionRecord<T>(value);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function readRecordIfPresent<T>(
|
|
371
|
+
path: string,
|
|
372
|
+
): Promise<SessionRecord<T> | undefined> {
|
|
373
|
+
try {
|
|
374
|
+
return await readRecord<T>(path);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (isCode(error, "ENOENT")) return undefined;
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
|
|
382
|
+
if (typeof value !== "object" || value === null)
|
|
383
|
+
throw new SessionValidationError("session record must be an object");
|
|
384
|
+
const record = value as Record<string, unknown>;
|
|
385
|
+
assertSessionId(record.sessionId);
|
|
386
|
+
if (!Number.isSafeInteger(record.revision) || (record.revision as number) < 0)
|
|
387
|
+
throw new SessionValidationError("session revision must be a safe integer");
|
|
388
|
+
if (
|
|
389
|
+
typeof record.updatedAt !== "string" ||
|
|
390
|
+
record.updatedAt.length === 0 ||
|
|
391
|
+
record.updatedAt.length > MAX_TIMESTAMP_LENGTH
|
|
392
|
+
)
|
|
393
|
+
throw new SessionValidationError("session updatedAt is invalid");
|
|
394
|
+
if (!Array.isArray(record.events) || record.events.length > MAX_EVENTS)
|
|
395
|
+
throw new SessionValidationError(
|
|
396
|
+
`session has more than ${MAX_EVENTS} events`,
|
|
397
|
+
);
|
|
398
|
+
if (record.revision !== record.events.length)
|
|
399
|
+
throw new SessionValidationError(
|
|
400
|
+
"session revision does not match its event log",
|
|
401
|
+
);
|
|
402
|
+
if (!("composition" in record))
|
|
403
|
+
throw new SessionValidationError("session composition is missing");
|
|
404
|
+
if (record.composition === undefined)
|
|
405
|
+
throw new SessionValidationError("session composition is not JSON data");
|
|
406
|
+
const events = record.events.map((event, index) => {
|
|
407
|
+
if (typeof event !== "object" || event === null)
|
|
408
|
+
throw new SessionValidationError("session event must be an object");
|
|
409
|
+
const candidate = event as Record<string, unknown>;
|
|
410
|
+
assertSessionId(candidate.id);
|
|
411
|
+
if (
|
|
412
|
+
!Number.isSafeInteger(candidate.revision) ||
|
|
413
|
+
candidate.revision !== index + 1
|
|
414
|
+
)
|
|
415
|
+
throw new SessionValidationError("session event revisions are invalid");
|
|
416
|
+
if (
|
|
417
|
+
typeof candidate.kind !== "string" ||
|
|
418
|
+
candidate.kind.length === 0 ||
|
|
419
|
+
candidate.kind.length > MAX_EVENT_KIND_LENGTH
|
|
420
|
+
)
|
|
421
|
+
throw new SessionValidationError("session event kind is invalid");
|
|
422
|
+
if (
|
|
423
|
+
typeof candidate.at !== "string" ||
|
|
424
|
+
candidate.at.length > MAX_TIMESTAMP_LENGTH
|
|
425
|
+
)
|
|
426
|
+
throw new SessionValidationError("session event timestamp is invalid");
|
|
427
|
+
const payloadJson = stringifyJson(
|
|
428
|
+
candidate.payload,
|
|
429
|
+
"session event payload",
|
|
430
|
+
);
|
|
431
|
+
if (Buffer.byteLength(payloadJson, "utf8") > MAX_EVENT_BYTES)
|
|
432
|
+
throw new SessionValidationError(
|
|
433
|
+
`session event exceeds ${MAX_EVENT_BYTES} bytes`,
|
|
434
|
+
);
|
|
435
|
+
return candidate as unknown as SessionEvent;
|
|
436
|
+
});
|
|
437
|
+
let meta: SessionMeta;
|
|
438
|
+
try {
|
|
439
|
+
meta = parseSessionMeta(record.meta, {
|
|
440
|
+
sessionId: record.sessionId,
|
|
441
|
+
updatedAt: record.updatedAt,
|
|
442
|
+
});
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (error instanceof MetaValidationError)
|
|
445
|
+
throw new SessionValidationError(error.message);
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
sessionId: record.sessionId,
|
|
450
|
+
revision: record.revision as number,
|
|
451
|
+
updatedAt: record.updatedAt,
|
|
452
|
+
composition: record.composition as T,
|
|
453
|
+
events,
|
|
454
|
+
meta,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function assertSessionId(value: unknown): asserts value is string {
|
|
459
|
+
if (
|
|
460
|
+
typeof value !== "string" ||
|
|
461
|
+
value.length === 0 ||
|
|
462
|
+
value.length > MAX_SESSION_ID_LENGTH ||
|
|
463
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) ||
|
|
464
|
+
value === "." ||
|
|
465
|
+
value === ".."
|
|
466
|
+
)
|
|
467
|
+
throw new SessionValidationError(
|
|
468
|
+
"session id contains unsafe path characters",
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function isCode(error: unknown, code: string): boolean {
|
|
473
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function stringifyJson(value: unknown, label: string): string {
|
|
477
|
+
try {
|
|
478
|
+
const encoded = JSON.stringify(value);
|
|
479
|
+
if (encoded === undefined)
|
|
480
|
+
throw new SessionValidationError(`${label} must be JSON data`);
|
|
481
|
+
return encoded;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (error instanceof SessionValidationError) throw error;
|
|
484
|
+
throw new SessionValidationError(`${label} must be JSON data`);
|
|
485
|
+
}
|
|
486
|
+
}
|