@cueloop/daemon 0.1.0-alpha.63 → 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.
@@ -57,8 +57,10 @@ describe("parseParams", () => {
57
57
  throw new Error("should have thrown");
58
58
  } catch (err) {
59
59
  expect(err).toBeInstanceOf(DaemonError);
60
- expect((err as DaemonError).code).toBe("invalid_params");
61
- expect((err as DaemonError).message).toContain("session.create: workspace");
60
+ if (err instanceof DaemonError) {
61
+ expect(err.code).toBe("invalid_params");
62
+ expect(err.message).toContain("session.create: workspace");
63
+ }
62
64
  }
63
65
  });
64
66
 
@@ -172,7 +174,7 @@ describe("validateSessionRecord", () => {
172
174
  * @cueloop/schema shows up here too.
173
175
  */
174
176
  describe("wire pins", () => {
175
- const keys = (subject: object) => Object.keys(subject).sort();
177
+ const keys = <Subject extends object>(subject: Subject) => Object.keys(subject).sort();
176
178
  const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
177
179
 
178
180
  const fullMeta: Required<ArtifactMeta> = {
@@ -190,6 +192,7 @@ describe("wire pins", () => {
190
192
  prefix: "p",
191
193
  suffix: "s",
192
194
  blockIndex: 0,
195
+ endBlockIndex: 0,
193
196
  start: 0,
194
197
  end: 1,
195
198
  selector: "div.card",
@@ -201,6 +204,7 @@ describe("wire pins", () => {
201
204
  body: "b",
202
205
  orphan: false,
203
206
  author: "SHA256:abc",
207
+ replyTo: "a0",
204
208
  resolution: { revision: 2, source: "agent" },
205
209
  createdAt: "now",
206
210
  };
@@ -231,6 +235,36 @@ describe("wire pins", () => {
231
235
  artifact: fullArtifact,
232
236
  revisions: [fullRevision],
233
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",
234
268
  workingCopy: "# P edited",
235
269
  viewedPaths: ["src/a.ts"],
236
270
  verdict: fullVerdict,
@@ -241,6 +275,29 @@ describe("wire pins", () => {
241
275
  participants: [fullIdentity],
242
276
  };
243
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
+
244
301
  test("schema key sets match the schema types", () => {
245
302
  expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
246
303
  expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
package/src/validate.ts CHANGED
@@ -20,11 +20,15 @@ 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";
27
30
  import { DaemonError } from "./errors";
31
+ import type { Request } from "./protocol";
28
32
 
29
33
  /**
30
34
  * Drift guard for every hand-mirrored shape below. v.object strips keys it
@@ -74,6 +78,7 @@ export const AnchorSchema = v.object({
74
78
  prefix: v.optional(v.string(), ""),
75
79
  suffix: v.optional(v.string(), ""),
76
80
  blockIndex: v.optional(v.number()),
81
+ endBlockIndex: v.optional(v.number()),
77
82
  start: v.optional(v.number()),
78
83
  end: v.optional(v.number()),
79
84
  selector: v.optional(v.string()),
@@ -88,6 +93,7 @@ export const AnnotationSchema = v.object({
88
93
  body: v.string(),
89
94
  orphan: v.optional(v.boolean()),
90
95
  author: v.optional(v.string()),
96
+ replyTo: v.optional(NonEmpty),
91
97
  resolution: v.optional(
92
98
  v.object({
93
99
  revision: v.number(),
@@ -136,8 +142,39 @@ export const Params = {
136
142
  authorName: v.optional(v.string()),
137
143
  }),
138
144
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
145
+ "session.setParticipantName": v.object({ id: SessionId, author: NonEmpty, name: NonEmpty }),
139
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
+ }),
140
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 }),
141
178
  "session.refreshDiff": v.object({ id: SessionId }),
142
179
  "session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
143
180
  "session.delete": v.object({ id: SessionId }),
@@ -145,6 +182,10 @@ export const Params = {
145
182
  id: SessionId,
146
183
  annotations: v.array(FullAnnotationSchema),
147
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
+ ),
148
189
  }),
149
190
  "session.resolve": v.object({
150
191
  id: SessionId,
@@ -159,7 +200,13 @@ export const Params = {
159
200
  }),
160
201
  "events.subscribe": v.object({}),
161
202
  "daemon.ping": v.object({}),
162
- "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
+ }),
163
210
  "daemon.shutdown": v.object({}),
164
211
  // herdr adapter scratch: the review's opened tab, kept off the session record.
165
212
  "herdr.getTab": v.object({ id: SessionId }),
@@ -175,7 +222,7 @@ export function isKnownMethod(method: string): method is MethodName {
175
222
  /** Validate params for a method, or throw a DaemonError the client can read. */
176
223
  export function parseParams<M extends MethodName>(
177
224
  method: M,
178
- params: unknown,
225
+ params: Request["params"],
179
226
  ): v.InferOutput<(typeof Params)[M]> {
180
227
  const result = v.safeParse(Params[method], params ?? {});
181
228
 
@@ -205,6 +252,47 @@ export const VerdictSchema = v.object({
205
252
  resolvedAt: v.string(),
206
253
  } satisfies EntriesOf<Verdict>);
207
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
+
208
296
  /** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
209
297
  export const SessionRecordSchema = v.object({
210
298
  schemaVersion: v.literal(SCHEMA_VERSION),
@@ -213,18 +301,31 @@ export const SessionRecordSchema = v.object({
213
301
  artifact: ArtifactSchema,
214
302
  revisions: v.array(RevisionSchema),
215
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
+ ),
216
314
  workingCopy: v.optional(v.string()),
217
315
  viewedPaths: v.optional(v.array(v.string())),
218
316
  verdict: v.nullable(VerdictSchema),
219
317
  status: v.picklist(["pending", "resolved"]),
220
318
  createdAt: v.string(),
319
+ shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
320
+ parentSessionId: v.optional(v.string()),
221
321
  shareId: v.optional(v.string()),
322
+ shareBranch: v.optional(v.string()),
222
323
  owner: v.optional(v.string()),
223
324
  participants: v.optional(v.array(IdentitySchema)),
224
325
  } satisfies EntriesOf<ReviewSession>);
225
326
 
226
327
  export function validateSessionRecord(
227
- raw: unknown,
328
+ raw: Parameters<typeof v.safeParse>[1],
228
329
  ): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
229
330
  const result = v.safeParse(SessionRecordSchema, raw);
230
331