@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.
- 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 +25 -4
- 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 +61 -1
- package/src/validate.ts +103 -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
|
};
|
|
@@ -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> = {
|
|
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
|
-
|
|
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>);
|