@cueloop/daemon 0.1.0-alpha.69 → 0.1.0-alpha.70
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/package.json +2 -2
- package/src/api.test.ts +3 -3
- package/src/api.ts +45 -52
- package/src/capabilities.ts +1 -0
- package/src/client.ts +82 -79
- package/src/herdr-pane.test.ts +2 -2
- package/src/herdr-pane.ts +2 -2
- package/src/herdr-tab-store.ts +1 -1
- package/src/retention.test.ts +6 -6
- package/src/retention.ts +2 -2
- package/src/review.test.ts +2 -2
- package/src/review.ts +5 -5
- package/src/server.ts +5 -0
- package/src/share-blob.ts +6 -6
- package/src/store.conformance.test.ts +9 -9
- package/src/store.jsonl.test.ts +20 -20
- package/src/store.ts +35 -24
- package/src/testing/store-conformance.ts +18 -18
- package/src/validate.test.ts +11 -11
- package/src/validate.ts +17 -6
package/src/store.jsonl.test.ts
CHANGED
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
} from "node:fs";
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { join } from "node:path";
|
|
19
|
-
import {
|
|
19
|
+
import { ThreadStore } from "./store";
|
|
20
20
|
import { migratedSessionsDir, sessionsDir, threadBucket } from "./paths";
|
|
21
|
-
import {
|
|
21
|
+
import { createTestThreadRecord } from "./testing/store-conformance";
|
|
22
22
|
|
|
23
23
|
function tmpHome(): string {
|
|
24
24
|
return mkdtempSync(join(tmpdir(), "cueloop-jsonl-"));
|
|
@@ -27,10 +27,10 @@ function tmpHome(): string {
|
|
|
27
27
|
describe("JSONL thread store", () => {
|
|
28
28
|
test("a thread is written under a per-project bucket keyed by root commit", () => {
|
|
29
29
|
const home = tmpHome();
|
|
30
|
-
const store = new
|
|
30
|
+
const store = new ThreadStore(home);
|
|
31
31
|
store.recover();
|
|
32
32
|
store.upsert(
|
|
33
|
-
|
|
33
|
+
createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
|
|
34
34
|
workspace: { repoRoot: "/repo", branch: "main", rootCommit: "abcdef0123456789" },
|
|
35
35
|
}),
|
|
36
36
|
);
|
|
@@ -51,9 +51,9 @@ describe("JSONL thread store", () => {
|
|
|
51
51
|
|
|
52
52
|
test("a repo-less thread lands in the standalone bucket", () => {
|
|
53
53
|
const home = tmpHome();
|
|
54
|
-
const store = new
|
|
54
|
+
const store = new ThreadStore(home);
|
|
55
55
|
store.recover();
|
|
56
|
-
store.upsert(
|
|
56
|
+
store.upsert(createTestThreadRecord("ses_s", "2026-09-01T00:00:00.000Z"));
|
|
57
57
|
|
|
58
58
|
expect(readdirSync(threadBucket(undefined, home))).toContain("ses_s.jsonl");
|
|
59
59
|
});
|
|
@@ -61,10 +61,10 @@ describe("JSONL thread store", () => {
|
|
|
61
61
|
test("boot migrates the old whole-record file and parks the original", () => {
|
|
62
62
|
const home = tmpHome();
|
|
63
63
|
mkdirSync(sessionsDir(home), { recursive: true });
|
|
64
|
-
const legacy =
|
|
64
|
+
const legacy = createTestThreadRecord("ses_old", "2026-09-01T00:00:00.000Z");
|
|
65
65
|
writeFileSync(join(sessionsDir(home), "ses_old.json"), JSON.stringify(legacy));
|
|
66
66
|
|
|
67
|
-
const store = new
|
|
67
|
+
const store = new ThreadStore(home);
|
|
68
68
|
const report = store.recover();
|
|
69
69
|
|
|
70
70
|
expect(report.recovered).toEqual(["ses_old"]);
|
|
@@ -79,10 +79,10 @@ describe("JSONL thread store", () => {
|
|
|
79
79
|
test("migration never overwrites a newer JSONL with an older legacy snapshot", () => {
|
|
80
80
|
const home = tmpHome();
|
|
81
81
|
// a newer JSONL already holds this thread (e.g. written after a rollback)
|
|
82
|
-
const store = new
|
|
82
|
+
const store = new ThreadStore(home);
|
|
83
83
|
store.recover();
|
|
84
84
|
store.upsert(
|
|
85
|
-
|
|
85
|
+
createTestThreadRecord("ses_x", "2026-09-01T00:00:00.000Z", {
|
|
86
86
|
status: "pending",
|
|
87
87
|
workspace: { repoRoot: "/repo", branch: "newer" },
|
|
88
88
|
}),
|
|
@@ -92,13 +92,13 @@ describe("JSONL thread store", () => {
|
|
|
92
92
|
writeFileSync(
|
|
93
93
|
join(sessionsDir(home), "ses_x.json"),
|
|
94
94
|
JSON.stringify(
|
|
95
|
-
|
|
95
|
+
createTestThreadRecord("ses_x", "2026-09-01T00:00:00.000Z", {
|
|
96
96
|
workspace: { repoRoot: "/repo", branch: "older" },
|
|
97
97
|
}),
|
|
98
98
|
),
|
|
99
99
|
);
|
|
100
100
|
|
|
101
|
-
const reopened = new
|
|
101
|
+
const reopened = new ThreadStore(home);
|
|
102
102
|
reopened.recover();
|
|
103
103
|
// the newer JSONL wins; the legacy snapshot is parked, not applied
|
|
104
104
|
expect(reopened.get("ses_x")?.workspace.branch).toBe("newer");
|
|
@@ -107,12 +107,12 @@ describe("JSONL thread store", () => {
|
|
|
107
107
|
|
|
108
108
|
test("a long log compacts to a single line, and the record survives it", () => {
|
|
109
109
|
const home = tmpHome();
|
|
110
|
-
const store = new
|
|
110
|
+
const store = new ThreadStore(home);
|
|
111
111
|
store.recover();
|
|
112
112
|
// more upserts than the compaction threshold
|
|
113
113
|
for (let iteration = 0; iteration < 60; iteration++) {
|
|
114
114
|
store.upsert(
|
|
115
|
-
|
|
115
|
+
createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
|
|
116
116
|
status: "pending",
|
|
117
117
|
workspace: { repoRoot: "/repo", branch: `b${iteration}` },
|
|
118
118
|
}),
|
|
@@ -125,34 +125,34 @@ describe("JSONL thread store", () => {
|
|
|
125
125
|
.filter((l) => l.trim());
|
|
126
126
|
expect(lines.length).toBeLessThan(60);
|
|
127
127
|
// the latest snapshot survives the compaction and a restart
|
|
128
|
-
const reopened = new
|
|
128
|
+
const reopened = new ThreadStore(home);
|
|
129
129
|
reopened.recover();
|
|
130
130
|
expect(reopened.get("ses_a")?.workspace.branch).toBe("b59");
|
|
131
131
|
});
|
|
132
132
|
|
|
133
133
|
test("a crash's torn trailing line is skipped; the last valid snapshot wins", () => {
|
|
134
134
|
const home = tmpHome();
|
|
135
|
-
const store = new
|
|
135
|
+
const store = new ThreadStore(home);
|
|
136
136
|
store.recover();
|
|
137
|
-
store.upsert(
|
|
137
|
+
store.upsert(createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z"));
|
|
138
138
|
|
|
139
139
|
// simulate a crash mid-append: a half-written line at the end
|
|
140
140
|
const file = join(threadBucket(undefined, home), "ses_a.jsonl");
|
|
141
141
|
appendFileSync(file, '{"id":"ses_a","half');
|
|
142
142
|
|
|
143
|
-
const reopened = new
|
|
143
|
+
const reopened = new ThreadStore(home);
|
|
144
144
|
const report = reopened.recover();
|
|
145
145
|
expect(report.recovered).toEqual(["ses_a"]);
|
|
146
146
|
expect(reopened.get("ses_a")?.id).toBe("ses_a");
|
|
147
147
|
|
|
148
148
|
// an update after a torn recovery must not fuse onto the torn fragment and vanish on restart
|
|
149
149
|
reopened.upsert(
|
|
150
|
-
|
|
150
|
+
createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
|
|
151
151
|
status: "pending",
|
|
152
152
|
workspace: { repoRoot: "/repo", branch: "after-tear" },
|
|
153
153
|
}),
|
|
154
154
|
);
|
|
155
|
-
const restarted = new
|
|
155
|
+
const restarted = new ThreadStore(home);
|
|
156
156
|
restarted.recover();
|
|
157
157
|
expect(restarted.get("ses_a")?.workspace.branch).toBe("after-tear");
|
|
158
158
|
});
|
package/src/store.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* that fail to parse are skipped and reported, never deleted; records from before histories existed
|
|
8
8
|
* are given one on read.
|
|
9
9
|
*
|
|
10
|
-
* `
|
|
10
|
+
* `ThreadRepository` is the contract every adapter satisfies; the conformance
|
|
11
11
|
* suite in ./testing/store-conformance.ts pins it for the file store and the
|
|
12
12
|
* in-memory store alike.
|
|
13
13
|
*/
|
|
@@ -24,9 +24,9 @@ import {
|
|
|
24
24
|
writeFileSync,
|
|
25
25
|
} from "node:fs";
|
|
26
26
|
import { dirname, join } from "node:path";
|
|
27
|
-
import { historyFromLinear, type
|
|
27
|
+
import { historyFromLinear, type Thread } from "@cueloop/schema";
|
|
28
28
|
import { migratedSessionsDir, sessionsDir, threadBucket, threadsDir } from "./paths";
|
|
29
|
-
import {
|
|
29
|
+
import { validateThreadRecord } from "./validate";
|
|
30
30
|
|
|
31
31
|
/** A thread's log is rewritten to a single snapshot line once it grows past this many, so an
|
|
32
32
|
* active thread's file never bloats unbounded while writes stay cheap appends in between. */
|
|
@@ -38,13 +38,13 @@ export interface RecoveryReport {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** What the daemon needs from session storage. */
|
|
41
|
-
export interface
|
|
41
|
+
export interface ThreadRepository {
|
|
42
42
|
/** Load what is stored; called once on boot. */
|
|
43
43
|
recover(): RecoveryReport;
|
|
44
|
-
get(id: string):
|
|
44
|
+
get(id: string): Thread | undefined;
|
|
45
45
|
/** Every session, oldest first. */
|
|
46
|
-
list():
|
|
47
|
-
upsert(session:
|
|
46
|
+
list(): Thread[];
|
|
47
|
+
upsert(session: Thread): void;
|
|
48
48
|
/** True when a session was removed. */
|
|
49
49
|
delete(id: string): boolean;
|
|
50
50
|
}
|
|
@@ -54,19 +54,19 @@ export interface SessionRepository {
|
|
|
54
54
|
* one. A record with no revision has no head to derive from and keeps
|
|
55
55
|
* reading without a history - migration never loses a record.
|
|
56
56
|
*/
|
|
57
|
-
export function withHistory(session:
|
|
57
|
+
export function withHistory(session: Thread): Thread {
|
|
58
58
|
if (session.history || session.revisions.length === 0) return session;
|
|
59
59
|
|
|
60
60
|
return { ...session, history: historyFromLinear(session) };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** Records in the order `list()` promises: oldest first. */
|
|
64
|
-
function oldestFirst(sessions: Iterable<
|
|
64
|
+
function oldestFirst(sessions: Iterable<Thread>): Thread[] {
|
|
65
65
|
return [...sessions].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
export class
|
|
69
|
-
private sessions = new Map<string,
|
|
68
|
+
export class ThreadStore implements ThreadRepository {
|
|
69
|
+
private sessions = new Map<string, Thread>();
|
|
70
70
|
/** id -> its JSONL path (the bucket depends on the record's root commit, so it is tracked). */
|
|
71
71
|
private files = new Map<string, string>();
|
|
72
72
|
/** id -> lines in its log, so upsert knows when to compact instead of append. */
|
|
@@ -124,10 +124,10 @@ export class SessionStore implements SessionRepository {
|
|
|
124
124
|
for (const file of readdirSync(legacyDir)) {
|
|
125
125
|
if (!file.endsWith(".json")) continue;
|
|
126
126
|
const legacyPath = join(legacyDir, file);
|
|
127
|
-
let session:
|
|
127
|
+
let session: Thread;
|
|
128
128
|
|
|
129
129
|
try {
|
|
130
|
-
const parsed =
|
|
130
|
+
const parsed = validateThreadRecord(JSON.parse(readFileSync(legacyPath, "utf8")));
|
|
131
131
|
|
|
132
132
|
if (!parsed.ok) throw new Error(`invalid record - ${parsed.error}`);
|
|
133
133
|
session = withHistory(parsed.value);
|
|
@@ -155,7 +155,7 @@ export class SessionStore implements SessionRepository {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/** Rewrite the thread to a single snapshot line, atomically - the first write and every compaction. */
|
|
158
|
-
private writeWhole(session:
|
|
158
|
+
private writeWhole(session: Thread): void {
|
|
159
159
|
const filePath =
|
|
160
160
|
this.files.get(session.id) ??
|
|
161
161
|
join(threadBucket(session.workspace.rootCommit, this.home), `${session.id}.jsonl`);
|
|
@@ -169,15 +169,15 @@ export class SessionStore implements SessionRepository {
|
|
|
169
169
|
this.lineCounts.set(session.id, 1);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
get(id: string):
|
|
172
|
+
get(id: string): Thread | undefined {
|
|
173
173
|
return this.sessions.get(id);
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
list():
|
|
176
|
+
list(): Thread[] {
|
|
177
177
|
return oldestFirst(this.sessions.values());
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
upsert(session:
|
|
180
|
+
upsert(session: Thread): void {
|
|
181
181
|
this.sessions.set(session.id, session);
|
|
182
182
|
const filePath = this.files.get(session.id);
|
|
183
183
|
const lines = this.lineCounts.get(session.id) ?? 0;
|
|
@@ -223,7 +223,7 @@ function readThread(filePath: string) {
|
|
|
223
223
|
} catch {
|
|
224
224
|
continue;
|
|
225
225
|
}
|
|
226
|
-
const parsed =
|
|
226
|
+
const parsed = validateThreadRecord(record);
|
|
227
227
|
|
|
228
228
|
if (parsed.ok) {
|
|
229
229
|
return {
|
|
@@ -241,8 +241,8 @@ function readThread(filePath: string) {
|
|
|
241
241
|
* in for what a file store finds on recovery, so validation and migration are
|
|
242
242
|
* exercised the same way.
|
|
243
243
|
*/
|
|
244
|
-
export class
|
|
245
|
-
private sessions = new Map<string,
|
|
244
|
+
export class MemoryThreadStore implements ThreadRepository {
|
|
245
|
+
private sessions = new Map<string, Thread>();
|
|
246
246
|
|
|
247
247
|
constructor(private readonly seed: unknown[] = []) {}
|
|
248
248
|
|
|
@@ -250,7 +250,7 @@ export class MemorySessionStore implements SessionRepository {
|
|
|
250
250
|
const report: RecoveryReport = { recovered: [], skipped: [] };
|
|
251
251
|
|
|
252
252
|
this.seed.forEach((record, index) => {
|
|
253
|
-
const parsed =
|
|
253
|
+
const parsed = validateThreadRecord(record);
|
|
254
254
|
|
|
255
255
|
if (!parsed.ok) {
|
|
256
256
|
report.skipped.push({ file: `seed[${index}]`, error: `invalid record - ${parsed.error}` });
|
|
@@ -266,15 +266,15 @@ export class MemorySessionStore implements SessionRepository {
|
|
|
266
266
|
return report;
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
get(id: string):
|
|
269
|
+
get(id: string): Thread | undefined {
|
|
270
270
|
return this.sessions.get(id);
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
list():
|
|
273
|
+
list(): Thread[] {
|
|
274
274
|
return oldestFirst(this.sessions.values());
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
upsert(session:
|
|
277
|
+
upsert(session: Thread): void {
|
|
278
278
|
this.sessions.set(session.id, session);
|
|
279
279
|
}
|
|
280
280
|
|
|
@@ -282,3 +282,14 @@ export class MemorySessionStore implements SessionRepository {
|
|
|
282
282
|
return this.sessions.delete(id);
|
|
283
283
|
}
|
|
284
284
|
}
|
|
285
|
+
|
|
286
|
+
/** @deprecated use ThreadRepository */
|
|
287
|
+
export type SessionRepository = ThreadRepository;
|
|
288
|
+
/** @deprecated use ThreadStore */
|
|
289
|
+
export const SessionStore = ThreadStore;
|
|
290
|
+
/** @deprecated use ThreadStore */
|
|
291
|
+
export type SessionStore = ThreadStore;
|
|
292
|
+
/** @deprecated use MemoryThreadStore */
|
|
293
|
+
export const MemorySessionStore = MemoryThreadStore;
|
|
294
|
+
/** @deprecated use MemoryThreadStore */
|
|
295
|
+
export type MemorySessionStore = MemoryThreadStore;
|
|
@@ -8,21 +8,21 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, test } from "bun:test";
|
|
11
|
-
import { SCHEMA_VERSION, type
|
|
12
|
-
import type {
|
|
11
|
+
import { SCHEMA_VERSION, type Thread } from "@cueloop/schema";
|
|
12
|
+
import type { ThreadRepository } from "../store";
|
|
13
13
|
|
|
14
14
|
export interface StoreHarness {
|
|
15
15
|
/** A fresh, empty adapter that will recover `records` on `recover()`. */
|
|
16
|
-
open: (records: unknown[]) =>
|
|
16
|
+
open: (records: unknown[]) => ThreadRepository;
|
|
17
17
|
/** A new adapter over whatever `store` persisted, as after a daemon restart. */
|
|
18
|
-
restart: (store:
|
|
18
|
+
restart: (store: ThreadRepository) => ThreadRepository;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export function
|
|
21
|
+
export function createTestThreadRecord(
|
|
22
22
|
id: string,
|
|
23
23
|
createdAt: string,
|
|
24
|
-
overrides: Partial<
|
|
25
|
-
):
|
|
24
|
+
overrides: Partial<Thread> = {},
|
|
25
|
+
): Thread {
|
|
26
26
|
return {
|
|
27
27
|
schemaVersion: SCHEMA_VERSION,
|
|
28
28
|
id,
|
|
@@ -37,15 +37,15 @@ export function createTestSessionRecord(
|
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function
|
|
40
|
+
export function runThreadStoreConformance(name: string, harness: StoreHarness): void {
|
|
41
41
|
describe(`${name} - session repository contract`, () => {
|
|
42
42
|
test("a stored session reads back whole and lists oldest first", () => {
|
|
43
43
|
// Arrange
|
|
44
44
|
const store = harness.open([]);
|
|
45
45
|
|
|
46
46
|
store.recover();
|
|
47
|
-
const newer =
|
|
48
|
-
const older =
|
|
47
|
+
const newer = createTestThreadRecord("ses_b", "2026-09-02T00:00:00.000Z");
|
|
48
|
+
const older = createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z");
|
|
49
49
|
|
|
50
50
|
// Act
|
|
51
51
|
store.upsert(newer);
|
|
@@ -62,11 +62,11 @@ export function runSessionStoreConformance(name: string, harness: StoreHarness):
|
|
|
62
62
|
const store = harness.open([]);
|
|
63
63
|
|
|
64
64
|
store.recover();
|
|
65
|
-
store.upsert(
|
|
65
|
+
store.upsert(createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z"));
|
|
66
66
|
|
|
67
67
|
// Act
|
|
68
68
|
store.upsert(
|
|
69
|
-
|
|
69
|
+
createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", { status: "resolved" }),
|
|
70
70
|
);
|
|
71
71
|
|
|
72
72
|
// Assert
|
|
@@ -82,8 +82,8 @@ export function runSessionStoreConformance(name: string, harness: StoreHarness):
|
|
|
82
82
|
const store = harness.open([]);
|
|
83
83
|
|
|
84
84
|
store.recover();
|
|
85
|
-
store.upsert(
|
|
86
|
-
store.upsert(
|
|
85
|
+
store.upsert(createTestThreadRecord("ses_keep", "2026-09-01T00:00:00.000Z"));
|
|
86
|
+
store.upsert(createTestThreadRecord("ses_gone", "2026-09-02T00:00:00.000Z"));
|
|
87
87
|
store.delete("ses_gone");
|
|
88
88
|
|
|
89
89
|
// Act
|
|
@@ -98,7 +98,7 @@ export function runSessionStoreConformance(name: string, harness: StoreHarness):
|
|
|
98
98
|
|
|
99
99
|
test("a record without a history reads as a one-branch tree whose head is the artifact", () => {
|
|
100
100
|
// Arrange: written before histories existed, with two revisions and a comment
|
|
101
|
-
const legacy =
|
|
101
|
+
const legacy = createTestThreadRecord("ses_old", "2026-09-01T00:00:00.000Z", {
|
|
102
102
|
artifact: { type: "plan", content: "v2", meta: {} },
|
|
103
103
|
revisions: [
|
|
104
104
|
{ revision: 1, content: "v1", submittedAt: "2026-09-01T00:00:00.000Z" },
|
|
@@ -135,7 +135,7 @@ export function runSessionStoreConformance(name: string, harness: StoreHarness):
|
|
|
135
135
|
test("a record with no revision still recovers, without a history", () => {
|
|
136
136
|
// Arrange
|
|
137
137
|
const store = harness.open([
|
|
138
|
-
|
|
138
|
+
createTestThreadRecord("ses_empty", "2026-09-01T00:00:00.000Z", { revisions: [] }),
|
|
139
139
|
]);
|
|
140
140
|
|
|
141
141
|
// Act
|
|
@@ -148,7 +148,7 @@ export function runSessionStoreConformance(name: string, harness: StoreHarness):
|
|
|
148
148
|
|
|
149
149
|
test("a record that already carries a history keeps it untouched", () => {
|
|
150
150
|
// Arrange
|
|
151
|
-
const withTree =
|
|
151
|
+
const withTree = createTestThreadRecord("ses_tree", "2026-09-01T00:00:00.000Z", {
|
|
152
152
|
history: {
|
|
153
153
|
entries: [
|
|
154
154
|
{
|
|
@@ -177,7 +177,7 @@ export function runSessionStoreConformance(name: string, harness: StoreHarness):
|
|
|
177
177
|
test("a record that fails validation is skipped and reported, never dropped from the report", () => {
|
|
178
178
|
// Arrange
|
|
179
179
|
const store = harness.open([
|
|
180
|
-
|
|
180
|
+
createTestThreadRecord("ses_good", "2026-09-01T00:00:00.000Z"),
|
|
181
181
|
{ schemaVersion: SCHEMA_VERSION, id: "ses_bad" },
|
|
182
182
|
]);
|
|
183
183
|
|
package/src/validate.test.ts
CHANGED
|
@@ -11,12 +11,12 @@ import {
|
|
|
11
11
|
ArtifactSchema,
|
|
12
12
|
IdentitySchema,
|
|
13
13
|
RevisionSchema,
|
|
14
|
-
|
|
14
|
+
ThreadRecordSchema,
|
|
15
15
|
VerdictSchema,
|
|
16
16
|
WorkspaceSchema,
|
|
17
17
|
isKnownMethod,
|
|
18
18
|
parseParams,
|
|
19
|
-
|
|
19
|
+
validateThreadRecord,
|
|
20
20
|
} from "./validate";
|
|
21
21
|
import {
|
|
22
22
|
ARTIFACT_TYPES,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type Artifact,
|
|
27
27
|
type ArtifactMeta,
|
|
28
28
|
type Identity,
|
|
29
|
-
type
|
|
29
|
+
type Thread,
|
|
30
30
|
type Revision,
|
|
31
31
|
type Verdict,
|
|
32
32
|
type WorkspaceKey,
|
|
@@ -132,7 +132,7 @@ describe("parseParams", () => {
|
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
describe("
|
|
135
|
+
describe("validateThreadRecord", () => {
|
|
136
136
|
const record = {
|
|
137
137
|
schemaVersion: SCHEMA_VERSION,
|
|
138
138
|
id: "ses_1",
|
|
@@ -146,12 +146,12 @@ describe("validateSessionRecord", () => {
|
|
|
146
146
|
};
|
|
147
147
|
|
|
148
148
|
test("accepts a valid record", () => {
|
|
149
|
-
expect(
|
|
149
|
+
expect(validateThreadRecord(record).ok).toBe(true);
|
|
150
150
|
});
|
|
151
151
|
|
|
152
152
|
test("rejects a foreign schema version with a readable reason", () => {
|
|
153
153
|
// Act
|
|
154
|
-
const result =
|
|
154
|
+
const result = validateThreadRecord({ ...record, schemaVersion: "99" });
|
|
155
155
|
|
|
156
156
|
// Assert
|
|
157
157
|
expect(result.ok).toBe(false);
|
|
@@ -160,7 +160,7 @@ describe("validateSessionRecord", () => {
|
|
|
160
160
|
|
|
161
161
|
test("rejects a structurally broken record", () => {
|
|
162
162
|
// Act
|
|
163
|
-
const result =
|
|
163
|
+
const result = validateThreadRecord({ ...record, revisions: "nope" });
|
|
164
164
|
|
|
165
165
|
// Assert
|
|
166
166
|
expect(result.ok).toBe(false);
|
|
@@ -235,7 +235,7 @@ describe("wire pins", () => {
|
|
|
235
235
|
name: "Al",
|
|
236
236
|
handle: "abc",
|
|
237
237
|
};
|
|
238
|
-
const fullSession: Required<
|
|
238
|
+
const fullSession: Required<Thread> = {
|
|
239
239
|
schemaVersion: SCHEMA_VERSION,
|
|
240
240
|
id: "ses_1",
|
|
241
241
|
workspace: fullWorkspace,
|
|
@@ -298,7 +298,7 @@ describe("wire pins", () => {
|
|
|
298
298
|
};
|
|
299
299
|
|
|
300
300
|
// Act
|
|
301
|
-
const parsed =
|
|
301
|
+
const parsed = validateThreadRecord(record);
|
|
302
302
|
|
|
303
303
|
// Assert
|
|
304
304
|
expect(parsed.ok).toBe(false);
|
|
@@ -317,9 +317,9 @@ describe("wire pins", () => {
|
|
|
317
317
|
expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
|
|
318
318
|
expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
|
|
319
319
|
expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
|
|
320
|
-
expect(entryKeys(
|
|
320
|
+
expect(entryKeys(ThreadRecordSchema)).toEqual(keys(fullSession));
|
|
321
321
|
// persisted annotations carry the stamped createdAt
|
|
322
|
-
const stored =
|
|
322
|
+
const stored = ThreadRecordSchema.entries.annotations.item;
|
|
323
323
|
|
|
324
324
|
expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
|
|
325
325
|
});
|
package/src/validate.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type ArtifactMeta,
|
|
21
21
|
type DiffFileContents,
|
|
22
22
|
type Identity,
|
|
23
|
-
type
|
|
23
|
+
type Thread,
|
|
24
24
|
type HunkRejection,
|
|
25
25
|
type Revision,
|
|
26
26
|
type SessionHistory,
|
|
@@ -153,6 +153,12 @@ export const Params = {
|
|
|
153
153
|
annotation: AnnotationSchema,
|
|
154
154
|
authorName: v.optional(v.string()),
|
|
155
155
|
}),
|
|
156
|
+
// "session.comment" is the primary annotate method; "session.annotate" stays an accepted alias
|
|
157
|
+
"session.comment": v.object({
|
|
158
|
+
id: SessionId,
|
|
159
|
+
annotation: AnnotationSchema,
|
|
160
|
+
authorName: v.optional(v.string()),
|
|
161
|
+
}),
|
|
156
162
|
"session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
|
|
157
163
|
"session.setParticipantName": v.object({ id: SessionId, author: NonEmpty, name: NonEmpty }),
|
|
158
164
|
"session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
|
|
@@ -315,7 +321,7 @@ export const SessionHistorySchema = v.pipe(
|
|
|
315
321
|
);
|
|
316
322
|
|
|
317
323
|
/** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
|
|
318
|
-
export const
|
|
324
|
+
export const ThreadRecordSchema = v.object({
|
|
319
325
|
schemaVersion: v.literal(SCHEMA_VERSION),
|
|
320
326
|
id: NonEmpty,
|
|
321
327
|
workspace: WorkspaceSchema,
|
|
@@ -343,12 +349,12 @@ export const SessionRecordSchema = v.object({
|
|
|
343
349
|
shareBranch: v.optional(v.string()),
|
|
344
350
|
owner: v.optional(v.string()),
|
|
345
351
|
participants: v.optional(v.array(IdentitySchema)),
|
|
346
|
-
} satisfies EntriesOf<
|
|
352
|
+
} satisfies EntriesOf<Thread>);
|
|
347
353
|
|
|
348
|
-
export function
|
|
354
|
+
export function validateThreadRecord(
|
|
349
355
|
raw: Parameters<typeof v.safeParse>[1],
|
|
350
|
-
): { ok: true; value: v.InferOutput<typeof
|
|
351
|
-
const result = v.safeParse(
|
|
356
|
+
): { ok: true; value: v.InferOutput<typeof ThreadRecordSchema> } | { ok: false; error: string } {
|
|
357
|
+
const result = v.safeParse(ThreadRecordSchema, raw);
|
|
352
358
|
|
|
353
359
|
if (result.success) return { ok: true, value: result.output };
|
|
354
360
|
const issue = result.issues[0]!;
|
|
@@ -356,3 +362,8 @@ export function validateSessionRecord(
|
|
|
356
362
|
|
|
357
363
|
return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
|
|
358
364
|
}
|
|
365
|
+
|
|
366
|
+
/** @deprecated use ThreadRecordSchema */
|
|
367
|
+
export const SessionRecordSchema = ThreadRecordSchema;
|
|
368
|
+
/** @deprecated use validateThreadRecord */
|
|
369
|
+
export const validateSessionRecord = validateThreadRecord;
|