@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.
- package/package.json +5 -3
- package/src/api.test.ts +479 -1
- package/src/api.ts +452 -27
- package/src/capabilities.test.ts +28 -9
- package/src/capabilities.ts +52 -19
- package/src/client.ts +126 -18
- package/src/curate.ts +109 -0
- package/src/diff-hot-reload.test.ts +16 -0
- package/src/herdr-pane.test.ts +1 -1
- package/src/paths.ts +5 -0
- package/src/protocol.ts +3 -0
- package/src/review.ts +1 -1
- package/src/server.test.ts +229 -2
- package/src/server.ts +90 -7
- package/src/share-blob.test.ts +51 -1
- package/src/store.conformance.test.ts +41 -0
- package/src/store.ts +89 -12
- package/src/testing/store-conformance.ts +194 -0
- package/src/validate.test.ts +55 -0
- package/src/validate.ts +101 -1
|
@@ -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
|
+
}
|
package/src/validate.test.ts
CHANGED
|
@@ -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
|
-
|
|
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>);
|