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

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
  };
@@ -219,7 +221,12 @@ describe("wire pins", () => {
219
221
  resolvedAt: "now",
220
222
  };
221
223
  const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
222
- const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
224
+ const fullWorkspace: Required<WorkspaceKey> = {
225
+ repoRoot: "/repo",
226
+ branch: "main",
227
+ rootCommit: "11e7abbcf507a13ae5e4e7559c4b42dbb52237fe",
228
+ remote: "git@github.com:acme/repo.git",
229
+ };
223
230
  const fullIdentity: Required<Identity> = {
224
231
  id: "SHA256:abc",
225
232
  provider: "ssh",
@@ -233,6 +240,36 @@ describe("wire pins", () => {
233
240
  artifact: fullArtifact,
234
241
  revisions: [fullRevision],
235
242
  annotations: [fullAnnotation],
243
+ history: {
244
+ entries: [
245
+ {
246
+ id: "e1",
247
+ parentId: null,
248
+ type: "revision",
249
+ by: "agent",
250
+ content: "# P",
251
+ createdAt: "now",
252
+ },
253
+ { id: "e2", parentId: "e1", type: "comment", annotationId: "a1", createdAt: "now" },
254
+ { id: "e3", parentId: "e2", type: "comment-removed", annotationId: "a1", createdAt: "now" },
255
+ { id: "e4", parentId: "e3", type: "verdict", verdict: fullVerdict, createdAt: "now" },
256
+ {
257
+ id: "e5",
258
+ parentId: "e4",
259
+ type: "branch-summary",
260
+ text: "tried a thing",
261
+ abandoned: ["e3"],
262
+ createdAt: "now",
263
+ },
264
+ ],
265
+ tips: { main: "e5" },
266
+ branch: "main",
267
+ labels: { e1: "start" },
268
+ },
269
+ curation: [{ path: "src/a.ts", hunkIndex: 0, changeIndex: 1 }],
270
+ shelvedAnnotations: [fullAnnotation],
271
+ parentSessionId: "ses_0",
272
+ shareBranch: "main",
236
273
  workingCopy: "# P edited",
237
274
  viewedPaths: ["src/a.ts"],
238
275
  verdict: fullVerdict,
@@ -243,6 +280,29 @@ describe("wire pins", () => {
243
280
  participants: [fullIdentity],
244
281
  };
245
282
 
283
+ test("a history whose tree is broken is refused even when every entry is well-formed", () => {
284
+ // Arrange: a parent cycle
285
+ const record = {
286
+ ...fullSession,
287
+ history: {
288
+ entries: [
289
+ { id: "a", parentId: "b", type: "revision", by: "agent", content: "x", createdAt: "now" },
290
+ { id: "b", parentId: "a", type: "comment", annotationId: "a1", createdAt: "now" },
291
+ ],
292
+ tips: { main: "b" },
293
+ branch: "main",
294
+ labels: {},
295
+ },
296
+ };
297
+
298
+ // Act
299
+ const parsed = validateSessionRecord(record);
300
+
301
+ // Assert
302
+ expect(parsed.ok).toBe(false);
303
+ if (!parsed.ok) expect(parsed.error).toContain("history:");
304
+ });
305
+
246
306
  test("schema key sets match the schema types", () => {
247
307
  expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
248
308
  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";
@@ -41,6 +44,8 @@ const NonEmpty = v.pipe(v.string(), v.minLength(1));
41
44
  export const WorkspaceSchema = v.object({
42
45
  repoRoot: NonEmpty,
43
46
  branch: NonEmpty,
47
+ rootCommit: v.optional(NonEmpty),
48
+ remote: v.optional(NonEmpty),
44
49
  } satisfies EntriesOf<WorkspaceKey>);
45
50
 
46
51
  export const ArtifactMetaSchema = v.object({
@@ -75,6 +80,7 @@ export const AnchorSchema = v.object({
75
80
  prefix: v.optional(v.string(), ""),
76
81
  suffix: v.optional(v.string(), ""),
77
82
  blockIndex: v.optional(v.number()),
83
+ endBlockIndex: v.optional(v.number()),
78
84
  start: v.optional(v.number()),
79
85
  end: v.optional(v.number()),
80
86
  selector: v.optional(v.string()),
@@ -89,6 +95,7 @@ export const AnnotationSchema = v.object({
89
95
  body: v.string(),
90
96
  orphan: v.optional(v.boolean()),
91
97
  author: v.optional(v.string()),
98
+ replyTo: v.optional(NonEmpty),
92
99
  resolution: v.optional(
93
100
  v.object({
94
101
  revision: v.number(),
@@ -137,8 +144,39 @@ export const Params = {
137
144
  authorName: v.optional(v.string()),
138
145
  }),
139
146
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
147
+ "session.setParticipantName": v.object({ id: SessionId, author: NonEmpty, name: NonEmpty }),
140
148
  "session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
149
+ "session.cutBlock": v.object({
150
+ id: SessionId,
151
+ blockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
152
+ }),
153
+ "session.restoreBlock": v.object({
154
+ id: SessionId,
155
+ baseBlockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
156
+ line: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
157
+ }),
158
+ "session.curate": v.object({
159
+ id: SessionId,
160
+ rejections: v.array(
161
+ v.object({
162
+ path: NonEmpty,
163
+ hunkIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
164
+ changeIndex: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
165
+ } satisfies EntriesOf<HunkRejection>),
166
+ ),
167
+ }),
141
168
  "session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
169
+ "session.navigate": v.object({
170
+ id: SessionId,
171
+ entryId: NonEmpty,
172
+ summary: v.optional(v.string()),
173
+ // stand on this branch first, so a move on another branch is one request
174
+ branch: v.optional(NonEmpty),
175
+ }),
176
+ "session.branch": v.object({ id: SessionId, name: NonEmpty }),
177
+ "session.switch": v.object({ id: SessionId, branch: NonEmpty }),
178
+ "session.label": v.object({ id: SessionId, label: NonEmpty }),
179
+ "session.fork": v.object({ id: SessionId }),
142
180
  "session.refreshDiff": v.object({ id: SessionId }),
143
181
  "session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
144
182
  "session.delete": v.object({ id: SessionId }),
@@ -146,6 +184,10 @@ export const Params = {
146
184
  id: SessionId,
147
185
  annotations: v.array(FullAnnotationSchema),
148
186
  participants: v.optional(v.array(IdentitySchema)),
187
+ // the removal entries a share recorded, carried by id so a merge applies each once
188
+ removals: v.optional(
189
+ v.array(v.object({ id: NonEmpty, annotationId: NonEmpty, createdAt: v.string() })),
190
+ ),
149
191
  }),
150
192
  "session.resolve": v.object({
151
193
  id: SessionId,
@@ -160,7 +202,13 @@ export const Params = {
160
202
  }),
161
203
  "events.subscribe": v.object({}),
162
204
  "daemon.ping": v.object({}),
163
- "daemon.hello": v.object({ role: v.picklist(["owner", "collaborator", "agent"]) }),
205
+ // the owner token proves ownership; without it a request for owner stays a collaborator
206
+ "daemon.hello": v.object({
207
+ role: v.picklist(["owner", "collaborator", "agent"]),
208
+ token: v.optional(v.string()),
209
+ // the author a non-owner acts as, bound once for the connection
210
+ author: v.optional(NonEmpty),
211
+ }),
164
212
  "daemon.shutdown": v.object({}),
165
213
  // herdr adapter scratch: the review's opened tab, kept off the session record.
166
214
  "herdr.getTab": v.object({ id: SessionId }),
@@ -206,6 +254,47 @@ export const VerdictSchema = v.object({
206
254
  resolvedAt: v.string(),
207
255
  } satisfies EntriesOf<Verdict>);
208
256
 
257
+ const EntryBaseEntries = {
258
+ id: NonEmpty,
259
+ parentId: v.nullable(NonEmpty),
260
+ createdAt: v.string(),
261
+ };
262
+
263
+ /** One record of a session's history; the tree pointers plus the entry's own fields. */
264
+ export const SessionEntrySchema = v.variant("type", [
265
+ v.object({
266
+ ...EntryBaseEntries,
267
+ type: v.literal("revision"),
268
+ by: v.picklist(["agent", "reviewer"]),
269
+ content: v.string(),
270
+ }),
271
+ v.object({ ...EntryBaseEntries, type: v.literal("comment"), annotationId: NonEmpty }),
272
+ v.object({ ...EntryBaseEntries, type: v.literal("comment-removed"), annotationId: NonEmpty }),
273
+ v.object({ ...EntryBaseEntries, type: v.literal("verdict"), verdict: VerdictSchema }),
274
+ v.object({
275
+ ...EntryBaseEntries,
276
+ type: v.literal("branch-summary"),
277
+ text: v.string(),
278
+ abandoned: v.array(NonEmpty),
279
+ }),
280
+ ]);
281
+
282
+ export const SessionHistorySchema = v.pipe(
283
+ v.object({
284
+ entries: v.array(SessionEntrySchema),
285
+ tips: v.record(v.string(), NonEmpty),
286
+ branch: NonEmpty,
287
+ labels: v.record(v.string(), v.string()),
288
+ } satisfies EntriesOf<SessionHistory>),
289
+ // the shape is not enough: the tree itself must hold, or a walk hangs or rewires the path
290
+ v.rawCheck(({ dataset, addIssue }) => {
291
+ if (!dataset.typed) return;
292
+ const problem = validateHistory(dataset.value);
293
+
294
+ if (problem !== null) addIssue({ message: `history: ${problem}` });
295
+ }),
296
+ );
297
+
209
298
  /** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
210
299
  export const SessionRecordSchema = v.object({
211
300
  schemaVersion: v.literal(SCHEMA_VERSION),
@@ -214,12 +303,25 @@ export const SessionRecordSchema = v.object({
214
303
  artifact: ArtifactSchema,
215
304
  revisions: v.array(RevisionSchema),
216
305
  annotations: v.array(FullAnnotationSchema),
306
+ history: v.optional(SessionHistorySchema),
307
+ curation: v.optional(
308
+ v.array(
309
+ v.object({
310
+ path: NonEmpty,
311
+ hunkIndex: v.number(),
312
+ changeIndex: v.optional(v.number()),
313
+ } satisfies EntriesOf<HunkRejection>),
314
+ ),
315
+ ),
217
316
  workingCopy: v.optional(v.string()),
218
317
  viewedPaths: v.optional(v.array(v.string())),
219
318
  verdict: v.nullable(VerdictSchema),
220
319
  status: v.picklist(["pending", "resolved"]),
221
320
  createdAt: v.string(),
321
+ shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
322
+ parentSessionId: v.optional(v.string()),
222
323
  shareId: v.optional(v.string()),
324
+ shareBranch: v.optional(v.string()),
223
325
  owner: v.optional(v.string()),
224
326
  participants: v.optional(v.array(IdentitySchema)),
225
327
  } satisfies EntriesOf<ReviewSession>);