@cueloop/daemon 0.1.0-alpha.64 → 0.1.0-alpha.65

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.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The session repository contract as executable cases. Every adapter runs the
3
+ * same suite: what a store returns, in which order, what survives a restart,
4
+ * what a bad record does, and how a record without a history reads.
5
+ *
6
+ * `restart` gives back a fresh adapter over the same persisted state (or the
7
+ * same seed) so recovery is tested through the contract, never past it.
8
+ */
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { SCHEMA_VERSION, type ReviewSession } from "@cueloop/schema";
12
+ import type { SessionRepository } from "../store";
13
+
14
+ export interface StoreHarness {
15
+ /** A fresh, empty adapter that will recover `records` on `recover()`. */
16
+ open: (records: unknown[]) => SessionRepository;
17
+ /** A new adapter over whatever `store` persisted, as after a daemon restart. */
18
+ restart: (store: SessionRepository) => SessionRepository;
19
+ }
20
+
21
+ export function createTestSessionRecord(
22
+ id: string,
23
+ createdAt: string,
24
+ overrides: Partial<ReviewSession> = {},
25
+ ): ReviewSession {
26
+ return {
27
+ schemaVersion: SCHEMA_VERSION,
28
+ id,
29
+ workspace: { repoRoot: "/repo", branch: "main" },
30
+ artifact: { type: "plan", content: `# ${id}`, meta: {} },
31
+ revisions: [{ revision: 1, content: `# ${id}`, submittedAt: createdAt }],
32
+ annotations: [],
33
+ verdict: null,
34
+ status: "pending",
35
+ createdAt,
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ export function runSessionStoreConformance(name: string, harness: StoreHarness): void {
41
+ describe(`${name} - session repository contract`, () => {
42
+ test("a stored session reads back whole and lists oldest first", () => {
43
+ // Arrange
44
+ const store = harness.open([]);
45
+
46
+ store.recover();
47
+ const newer = createTestSessionRecord("ses_b", "2026-09-02T00:00:00.000Z");
48
+ const older = createTestSessionRecord("ses_a", "2026-09-01T00:00:00.000Z");
49
+
50
+ // Act
51
+ store.upsert(newer);
52
+ store.upsert(older);
53
+
54
+ // Assert
55
+ expect(store.get("ses_a")).toEqual(older);
56
+ expect(store.list().map((session) => session.id)).toEqual(["ses_a", "ses_b"]);
57
+ expect(store.get("ses_zzz")).toBeUndefined();
58
+ });
59
+
60
+ test("upsert replaces by id and delete reports whether anything was removed", () => {
61
+ // Arrange
62
+ const store = harness.open([]);
63
+
64
+ store.recover();
65
+ store.upsert(createTestSessionRecord("ses_a", "2026-09-01T00:00:00.000Z"));
66
+
67
+ // Act
68
+ store.upsert(
69
+ createTestSessionRecord("ses_a", "2026-09-01T00:00:00.000Z", { status: "resolved" }),
70
+ );
71
+
72
+ // Assert
73
+ expect(store.list()).toHaveLength(1);
74
+ expect(store.get("ses_a")?.status).toBe("resolved");
75
+ expect(store.delete("ses_a")).toBe(true);
76
+ expect(store.delete("ses_a")).toBe(false);
77
+ expect(store.list()).toEqual([]);
78
+ });
79
+
80
+ test("what was upserted survives a restart, deletions included", () => {
81
+ // Arrange
82
+ const store = harness.open([]);
83
+
84
+ store.recover();
85
+ store.upsert(createTestSessionRecord("ses_keep", "2026-09-01T00:00:00.000Z"));
86
+ store.upsert(createTestSessionRecord("ses_gone", "2026-09-02T00:00:00.000Z"));
87
+ store.delete("ses_gone");
88
+
89
+ // Act
90
+ const reopened = harness.restart(store);
91
+ const report = reopened.recover();
92
+
93
+ // Assert
94
+ expect(report.recovered).toEqual(["ses_keep"]);
95
+ expect(reopened.get("ses_keep")?.artifact.content).toBe("# ses_keep");
96
+ expect(reopened.get("ses_gone")).toBeUndefined();
97
+ });
98
+
99
+ test("a record without a history reads as a one-branch tree whose head is the artifact", () => {
100
+ // Arrange: written before histories existed, with two revisions and a comment
101
+ const legacy = createTestSessionRecord("ses_old", "2026-09-01T00:00:00.000Z", {
102
+ artifact: { type: "plan", content: "v2", meta: {} },
103
+ revisions: [
104
+ { revision: 1, content: "v1", submittedAt: "2026-09-01T00:00:00.000Z" },
105
+ { revision: 2, content: "v2", submittedAt: "2026-09-01T02:00:00.000Z" },
106
+ ],
107
+ annotations: [
108
+ {
109
+ id: "a1",
110
+ kind: "comment",
111
+ anchor: { quote: "v1", prefix: "", suffix: "" },
112
+ body: "why",
113
+ createdAt: "2026-09-01T01:00:00.000Z",
114
+ },
115
+ ],
116
+ });
117
+ const store = harness.open([legacy]);
118
+
119
+ // Act
120
+ const report = store.recover();
121
+ const history = store.get("ses_old")?.history;
122
+
123
+ // Assert
124
+ expect(report.recovered).toEqual(["ses_old"]);
125
+ expect(history?.branch).toBe("main");
126
+ expect(history?.entries.map((entry) => entry.type)).toEqual([
127
+ "revision",
128
+ "comment",
129
+ "revision",
130
+ ]);
131
+ expect(history?.entries.at(-1)).toMatchObject({ type: "revision", content: "v2" });
132
+ expect(history?.tips.main).toBe(history?.entries.at(-1)?.id);
133
+ });
134
+
135
+ test("a record with no revision still recovers, without a history", () => {
136
+ // Arrange
137
+ const store = harness.open([
138
+ createTestSessionRecord("ses_empty", "2026-09-01T00:00:00.000Z", { revisions: [] }),
139
+ ]);
140
+
141
+ // Act
142
+ const report = store.recover();
143
+
144
+ // Assert
145
+ expect(report.recovered).toEqual(["ses_empty"]);
146
+ expect(store.get("ses_empty")?.history).toBeUndefined();
147
+ });
148
+
149
+ test("a record that already carries a history keeps it untouched", () => {
150
+ // Arrange
151
+ const withTree = createTestSessionRecord("ses_tree", "2026-09-01T00:00:00.000Z", {
152
+ history: {
153
+ entries: [
154
+ {
155
+ id: "root",
156
+ parentId: null,
157
+ type: "revision",
158
+ by: "agent",
159
+ content: "# ses_tree",
160
+ createdAt: "2026-09-01T00:00:00.000Z",
161
+ },
162
+ ],
163
+ tips: { main: "root", ramble: "root" },
164
+ branch: "ramble",
165
+ labels: { root: "start" },
166
+ },
167
+ });
168
+ const store = harness.open([withTree]);
169
+
170
+ // Act
171
+ store.recover();
172
+
173
+ // Assert
174
+ expect(store.get("ses_tree")?.history).toEqual(withTree.history);
175
+ });
176
+
177
+ test("a record that fails validation is skipped and reported, never dropped from the report", () => {
178
+ // Arrange
179
+ const store = harness.open([
180
+ createTestSessionRecord("ses_good", "2026-09-01T00:00:00.000Z"),
181
+ { schemaVersion: SCHEMA_VERSION, id: "ses_bad" },
182
+ ]);
183
+
184
+ // Act
185
+ const report = store.recover();
186
+
187
+ // Assert
188
+ expect(report.recovered).toEqual(["ses_good"]);
189
+ expect(report.skipped).toHaveLength(1);
190
+ expect(report.skipped[0]!.error).toContain("invalid record");
191
+ expect(store.get("ses_bad")).toBeUndefined();
192
+ });
193
+ });
194
+ }
@@ -192,6 +192,7 @@ describe("wire pins", () => {
192
192
  prefix: "p",
193
193
  suffix: "s",
194
194
  blockIndex: 0,
195
+ endBlockIndex: 0,
195
196
  start: 0,
196
197
  end: 1,
197
198
  selector: "div.card",
@@ -203,6 +204,7 @@ describe("wire pins", () => {
203
204
  body: "b",
204
205
  orphan: false,
205
206
  author: "SHA256:abc",
207
+ replyTo: "a0",
206
208
  resolution: { revision: 2, source: "agent" },
207
209
  createdAt: "now",
208
210
  };
@@ -233,6 +235,36 @@ describe("wire pins", () => {
233
235
  artifact: fullArtifact,
234
236
  revisions: [fullRevision],
235
237
  annotations: [fullAnnotation],
238
+ history: {
239
+ entries: [
240
+ {
241
+ id: "e1",
242
+ parentId: null,
243
+ type: "revision",
244
+ by: "agent",
245
+ content: "# P",
246
+ createdAt: "now",
247
+ },
248
+ { id: "e2", parentId: "e1", type: "comment", annotationId: "a1", createdAt: "now" },
249
+ { id: "e3", parentId: "e2", type: "comment-removed", annotationId: "a1", createdAt: "now" },
250
+ { id: "e4", parentId: "e3", type: "verdict", verdict: fullVerdict, createdAt: "now" },
251
+ {
252
+ id: "e5",
253
+ parentId: "e4",
254
+ type: "branch-summary",
255
+ text: "tried a thing",
256
+ abandoned: ["e3"],
257
+ createdAt: "now",
258
+ },
259
+ ],
260
+ tips: { main: "e5" },
261
+ branch: "main",
262
+ labels: { e1: "start" },
263
+ },
264
+ curation: [{ path: "src/a.ts", hunkIndex: 0, changeIndex: 1 }],
265
+ shelvedAnnotations: [fullAnnotation],
266
+ parentSessionId: "ses_0",
267
+ shareBranch: "main",
236
268
  workingCopy: "# P edited",
237
269
  viewedPaths: ["src/a.ts"],
238
270
  verdict: fullVerdict,
@@ -243,6 +275,29 @@ describe("wire pins", () => {
243
275
  participants: [fullIdentity],
244
276
  };
245
277
 
278
+ test("a history whose tree is broken is refused even when every entry is well-formed", () => {
279
+ // Arrange: a parent cycle
280
+ const record = {
281
+ ...fullSession,
282
+ history: {
283
+ entries: [
284
+ { id: "a", parentId: "b", type: "revision", by: "agent", content: "x", createdAt: "now" },
285
+ { id: "b", parentId: "a", type: "comment", annotationId: "a1", createdAt: "now" },
286
+ ],
287
+ tips: { main: "b" },
288
+ branch: "main",
289
+ labels: {},
290
+ },
291
+ };
292
+
293
+ // Act
294
+ const parsed = validateSessionRecord(record);
295
+
296
+ // Assert
297
+ expect(parsed.ok).toBe(false);
298
+ if (!parsed.ok) expect(parsed.error).toContain("history:");
299
+ });
300
+
246
301
  test("schema key sets match the schema types", () => {
247
302
  expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
248
303
  expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
package/src/validate.ts CHANGED
@@ -20,7 +20,10 @@ import {
20
20
  type DiffFileContents,
21
21
  type Identity,
22
22
  type ReviewSession,
23
+ type HunkRejection,
23
24
  type Revision,
25
+ type SessionHistory,
26
+ validateHistory,
24
27
  type Verdict,
25
28
  type WorkspaceKey,
26
29
  } from "@cueloop/schema";
@@ -75,6 +78,7 @@ export const AnchorSchema = v.object({
75
78
  prefix: v.optional(v.string(), ""),
76
79
  suffix: v.optional(v.string(), ""),
77
80
  blockIndex: v.optional(v.number()),
81
+ endBlockIndex: v.optional(v.number()),
78
82
  start: v.optional(v.number()),
79
83
  end: v.optional(v.number()),
80
84
  selector: v.optional(v.string()),
@@ -89,6 +93,7 @@ export const AnnotationSchema = v.object({
89
93
  body: v.string(),
90
94
  orphan: v.optional(v.boolean()),
91
95
  author: v.optional(v.string()),
96
+ replyTo: v.optional(NonEmpty),
92
97
  resolution: v.optional(
93
98
  v.object({
94
99
  revision: v.number(),
@@ -137,8 +142,39 @@ export const Params = {
137
142
  authorName: v.optional(v.string()),
138
143
  }),
139
144
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
145
+ "session.setParticipantName": v.object({ id: SessionId, author: NonEmpty, name: NonEmpty }),
140
146
  "session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
147
+ "session.cutBlock": v.object({
148
+ id: SessionId,
149
+ blockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
150
+ }),
151
+ "session.restoreBlock": v.object({
152
+ id: SessionId,
153
+ baseBlockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
154
+ line: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
155
+ }),
156
+ "session.curate": v.object({
157
+ id: SessionId,
158
+ rejections: v.array(
159
+ v.object({
160
+ path: NonEmpty,
161
+ hunkIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
162
+ changeIndex: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
163
+ } satisfies EntriesOf<HunkRejection>),
164
+ ),
165
+ }),
141
166
  "session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
167
+ "session.navigate": v.object({
168
+ id: SessionId,
169
+ entryId: NonEmpty,
170
+ summary: v.optional(v.string()),
171
+ // stand on this branch first, so a move on another branch is one request
172
+ branch: v.optional(NonEmpty),
173
+ }),
174
+ "session.branch": v.object({ id: SessionId, name: NonEmpty }),
175
+ "session.switch": v.object({ id: SessionId, branch: NonEmpty }),
176
+ "session.label": v.object({ id: SessionId, label: NonEmpty }),
177
+ "session.fork": v.object({ id: SessionId }),
142
178
  "session.refreshDiff": v.object({ id: SessionId }),
143
179
  "session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
144
180
  "session.delete": v.object({ id: SessionId }),
@@ -146,6 +182,10 @@ export const Params = {
146
182
  id: SessionId,
147
183
  annotations: v.array(FullAnnotationSchema),
148
184
  participants: v.optional(v.array(IdentitySchema)),
185
+ // the removal entries a share recorded, carried by id so a merge applies each once
186
+ removals: v.optional(
187
+ v.array(v.object({ id: NonEmpty, annotationId: NonEmpty, createdAt: v.string() })),
188
+ ),
149
189
  }),
150
190
  "session.resolve": v.object({
151
191
  id: SessionId,
@@ -160,7 +200,13 @@ export const Params = {
160
200
  }),
161
201
  "events.subscribe": v.object({}),
162
202
  "daemon.ping": v.object({}),
163
- "daemon.hello": v.object({ role: v.picklist(["owner", "collaborator", "agent"]) }),
203
+ // the owner token proves ownership; without it a request for owner stays a collaborator
204
+ "daemon.hello": v.object({
205
+ role: v.picklist(["owner", "collaborator", "agent"]),
206
+ token: v.optional(v.string()),
207
+ // the author a non-owner acts as, bound once for the connection
208
+ author: v.optional(NonEmpty),
209
+ }),
164
210
  "daemon.shutdown": v.object({}),
165
211
  // herdr adapter scratch: the review's opened tab, kept off the session record.
166
212
  "herdr.getTab": v.object({ id: SessionId }),
@@ -206,6 +252,47 @@ export const VerdictSchema = v.object({
206
252
  resolvedAt: v.string(),
207
253
  } satisfies EntriesOf<Verdict>);
208
254
 
255
+ const EntryBaseEntries = {
256
+ id: NonEmpty,
257
+ parentId: v.nullable(NonEmpty),
258
+ createdAt: v.string(),
259
+ };
260
+
261
+ /** One record of a session's history; the tree pointers plus the entry's own fields. */
262
+ export const SessionEntrySchema = v.variant("type", [
263
+ v.object({
264
+ ...EntryBaseEntries,
265
+ type: v.literal("revision"),
266
+ by: v.picklist(["agent", "reviewer"]),
267
+ content: v.string(),
268
+ }),
269
+ v.object({ ...EntryBaseEntries, type: v.literal("comment"), annotationId: NonEmpty }),
270
+ v.object({ ...EntryBaseEntries, type: v.literal("comment-removed"), annotationId: NonEmpty }),
271
+ v.object({ ...EntryBaseEntries, type: v.literal("verdict"), verdict: VerdictSchema }),
272
+ v.object({
273
+ ...EntryBaseEntries,
274
+ type: v.literal("branch-summary"),
275
+ text: v.string(),
276
+ abandoned: v.array(NonEmpty),
277
+ }),
278
+ ]);
279
+
280
+ export const SessionHistorySchema = v.pipe(
281
+ v.object({
282
+ entries: v.array(SessionEntrySchema),
283
+ tips: v.record(v.string(), NonEmpty),
284
+ branch: NonEmpty,
285
+ labels: v.record(v.string(), v.string()),
286
+ } satisfies EntriesOf<SessionHistory>),
287
+ // the shape is not enough: the tree itself must hold, or a walk hangs or rewires the path
288
+ v.rawCheck(({ dataset, addIssue }) => {
289
+ if (!dataset.typed) return;
290
+ const problem = validateHistory(dataset.value);
291
+
292
+ if (problem !== null) addIssue({ message: `history: ${problem}` });
293
+ }),
294
+ );
295
+
209
296
  /** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
210
297
  export const SessionRecordSchema = v.object({
211
298
  schemaVersion: v.literal(SCHEMA_VERSION),
@@ -214,12 +301,25 @@ export const SessionRecordSchema = v.object({
214
301
  artifact: ArtifactSchema,
215
302
  revisions: v.array(RevisionSchema),
216
303
  annotations: v.array(FullAnnotationSchema),
304
+ history: v.optional(SessionHistorySchema),
305
+ curation: v.optional(
306
+ v.array(
307
+ v.object({
308
+ path: NonEmpty,
309
+ hunkIndex: v.number(),
310
+ changeIndex: v.optional(v.number()),
311
+ } satisfies EntriesOf<HunkRejection>),
312
+ ),
313
+ ),
217
314
  workingCopy: v.optional(v.string()),
218
315
  viewedPaths: v.optional(v.array(v.string())),
219
316
  verdict: v.nullable(VerdictSchema),
220
317
  status: v.picklist(["pending", "resolved"]),
221
318
  createdAt: v.string(),
319
+ shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
320
+ parentSessionId: v.optional(v.string()),
222
321
  shareId: v.optional(v.string()),
322
+ shareBranch: v.optional(v.string()),
223
323
  owner: v.optional(v.string()),
224
324
  participants: v.optional(v.array(IdentitySchema)),
225
325
  } satisfies EntriesOf<ReviewSession>);