@cueloop/daemon 0.1.0-alpha.4 → 0.1.0-alpha.41

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.
@@ -1,7 +1,35 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DaemonCore } from "./api";
2
6
  import { DaemonError } from "./errors";
3
- import { isKnownMethod, parseParams, validateSessionRecord } from "./validate";
4
- import { SCHEMA_VERSION } from "@cueloop/schema";
7
+ import {
8
+ AnchorSchema,
9
+ AnnotationSchema,
10
+ ArtifactMetaSchema,
11
+ ArtifactSchema,
12
+ IdentitySchema,
13
+ RevisionSchema,
14
+ SessionRecordSchema,
15
+ VerdictSchema,
16
+ WorkspaceSchema,
17
+ isKnownMethod,
18
+ parseParams,
19
+ validateSessionRecord,
20
+ } from "./validate";
21
+ import {
22
+ SCHEMA_VERSION,
23
+ type Anchor,
24
+ type Annotation,
25
+ type Artifact,
26
+ type ArtifactMeta,
27
+ type Identity,
28
+ type ReviewSession,
29
+ type Revision,
30
+ type Verdict,
31
+ type WorkspaceKey,
32
+ } from "@cueloop/schema";
5
33
 
6
34
  describe("method allowlist", () => {
7
35
  test("known and unknown methods", () => {
@@ -12,11 +40,14 @@ describe("method allowlist", () => {
12
40
 
13
41
  describe("parseParams", () => {
14
42
  test("accepts a well-formed create and defaults meta", () => {
15
- const p = parseParams("session.create", {
43
+ // Act
44
+ const params = parseParams("session.create", {
16
45
  workspace: { repoRoot: "/repo", branch: "main" },
17
46
  artifact: { type: "plan", content: "# P" },
18
47
  });
19
- expect(p.artifact.meta).toEqual({});
48
+
49
+ // Assert
50
+ expect(params.artifact.meta).toEqual({});
20
51
  });
21
52
 
22
53
  test("rejects a missing workspace with a pathed message", () => {
@@ -46,21 +77,40 @@ describe("parseParams", () => {
46
77
  test("defaults and clamps the wait timeout", () => {
47
78
  expect(parseParams("session.wait", { id: "ses_1" }).timeoutMs).toBe(60_000);
48
79
  expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: -5 })).toThrow();
49
- expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 })).toThrow();
80
+ expect(() =>
81
+ parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 }),
82
+ ).toThrow();
50
83
  });
51
84
 
52
85
  test("verdict kinds are closed", () => {
53
86
  expect(parseParams("session.resolve", { id: "s", verdictKind: "approve" }).summary).toBe("");
54
- expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(/verdictKind/);
87
+ expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(
88
+ /verdictKind/,
89
+ );
55
90
  });
56
91
 
57
92
  test("annotation kinds stay open (extension kinds are allowed)", () => {
58
- const p = parseParams("session.annotate", {
93
+ // Act
94
+ const params = parseParams("session.annotate", {
59
95
  id: "s",
60
96
  annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
61
97
  });
62
- expect(p.annotation.kind).toBe("praise");
63
- expect(p.annotation.anchor.prefix).toBe("");
98
+
99
+ // Assert
100
+ expect(params.annotation.kind).toBe("praise");
101
+ expect(params.annotation.anchor.prefix).toBe("");
102
+ });
103
+
104
+ test("setViewed takes a full path list and rejects non-string entries", () => {
105
+ expect(
106
+ parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] })
107
+ .viewedPaths,
108
+ ).toEqual(["src/a.ts", "src/b.ts"]);
109
+ expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
110
+ expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(
111
+ /viewedPaths/,
112
+ );
113
+ expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
64
114
  });
65
115
 
66
116
  test("null params are treated as empty", () => {
@@ -86,13 +136,131 @@ describe("validateSessionRecord", () => {
86
136
  });
87
137
 
88
138
  test("rejects a foreign schema version with a readable reason", () => {
89
- const r = validateSessionRecord({ ...record, schemaVersion: "99" });
90
- expect(r.ok).toBe(false);
91
- if (!r.ok) expect(r.error).toContain("schemaVersion");
139
+ // Act
140
+ const result = validateSessionRecord({ ...record, schemaVersion: "99" });
141
+
142
+ // Assert
143
+ expect(result.ok).toBe(false);
144
+ if (!result.ok) expect(result.error).toContain("schemaVersion");
92
145
  });
93
146
 
94
147
  test("rejects a structurally broken record", () => {
95
- const r = validateSessionRecord({ ...record, revisions: "nope" });
96
- expect(r.ok).toBe(false);
148
+ // Act
149
+ const result = validateSessionRecord({ ...record, revisions: "nope" });
150
+
151
+ // Assert
152
+ expect(result.ok).toBe(false);
153
+ });
154
+ });
155
+
156
+ /**
157
+ * Wire pins: every schema's entries must cover exactly the keys of the
158
+ * schema type it mirrors. Runtime complement to the compile-time EntriesOf
159
+ * check - the samples are fully populated and typed, so a new field in
160
+ * @cueloop/schema shows up here too.
161
+ */
162
+ describe("wire pins", () => {
163
+ const keys = (subject: object) => Object.keys(subject).sort();
164
+ const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
165
+
166
+ const fullMeta: Required<ArtifactMeta> = {
167
+ cwd: "/repo",
168
+ agent: "claude-code",
169
+ agentSessionId: "sess-1",
170
+ planPath: "/repo/plan.md",
171
+ pr: "org/repo#1",
172
+ herdrPane: "%7",
173
+ title: "Plan",
174
+ };
175
+ const fullAnchor: Required<Anchor> = {
176
+ quote: "q",
177
+ prefix: "p",
178
+ suffix: "s",
179
+ blockIndex: 0,
180
+ start: 0,
181
+ end: 1,
182
+ };
183
+ const fullAnnotation: Required<Annotation> = {
184
+ id: "a1",
185
+ kind: "comment",
186
+ anchor: fullAnchor,
187
+ body: "b",
188
+ orphan: false,
189
+ author: "SHA256:abc",
190
+ resolution: { revision: 2, source: "agent" },
191
+ createdAt: "now",
192
+ };
193
+ const fullArtifact: Required<Artifact> = {
194
+ type: "plan",
195
+ content: "# P",
196
+ meta: fullMeta,
197
+ files: [{ path: "src/a.ts", oldContents: "old\n", newContents: "new\n", status: "modified" }],
198
+ };
199
+ const fullVerdict: Required<Verdict> = {
200
+ kind: "approve",
201
+ summary: "",
202
+ feedback: "",
203
+ resolvedAt: "now",
204
+ };
205
+ const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
206
+ const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
207
+ const fullIdentity: Required<Identity> = {
208
+ id: "SHA256:abc",
209
+ provider: "ssh",
210
+ name: "Al",
211
+ handle: "abc",
212
+ };
213
+ const fullSession: Required<ReviewSession> = {
214
+ schemaVersion: SCHEMA_VERSION,
215
+ id: "ses_1",
216
+ workspace: fullWorkspace,
217
+ artifact: fullArtifact,
218
+ revisions: [fullRevision],
219
+ annotations: [fullAnnotation],
220
+ workingCopy: "# P edited",
221
+ viewedPaths: ["src/a.ts"],
222
+ verdict: fullVerdict,
223
+ status: "pending",
224
+ createdAt: "now",
225
+ shareId: "p_abc123xy",
226
+ owner: "SHA256:owner",
227
+ participants: [fullIdentity],
228
+ };
229
+
230
+ test("schema key sets match the schema types", () => {
231
+ expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
232
+ expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
233
+ expect(entryKeys(ArtifactSchema)).toEqual(keys(fullArtifact));
234
+ expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
235
+ // wire annotations arrive without createdAt; the daemon stamps it
236
+ const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
237
+ expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
238
+ expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
239
+ expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
240
+ expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
241
+ expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
242
+ // persisted annotations carry the stamped createdAt
243
+ const stored = SessionRecordSchema.entries.annotations.item;
244
+ expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
245
+ });
246
+
247
+ test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
248
+ const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
249
+ try {
250
+ // Arrange
251
+ const params = parseParams("session.create", {
252
+ workspace: fullWorkspace,
253
+ artifact: { type: "plan", content: "# P", meta: fullMeta },
254
+ });
255
+ const core = new DaemonCore(home);
256
+
257
+ // Act
258
+ const created = core.sessionCreate(params);
259
+
260
+ // Assert
261
+ expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
262
+ } finally {
263
+ rmSync(home, { recursive: true, force: true });
264
+ }
97
265
  });
98
266
  });
package/src/validate.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Runtime validation of the wire boundary (#14). Everything arriving over the
2
+ * Runtime validation of the wire boundary. Everything arriving over the
3
3
  * socket is untrusted JSON: adapters, the CLI, extensions, and any script a
4
4
  * user writes all speak this protocol. Validating here means DaemonCore only
5
5
  * ever sees well-formed input, and a malformed request gets a precise error
@@ -10,31 +10,60 @@
10
10
  */
11
11
 
12
12
  import * as v from "valibot";
13
- import { SCHEMA_VERSION } from "@cueloop/schema";
13
+ import {
14
+ SCHEMA_VERSION,
15
+ type Anchor,
16
+ type Annotation,
17
+ type Artifact,
18
+ type ArtifactMeta,
19
+ type DiffFileContents,
20
+ type Identity,
21
+ type ReviewSession,
22
+ type Revision,
23
+ type Verdict,
24
+ type WorkspaceKey,
25
+ } from "@cueloop/schema";
14
26
  import { DaemonError } from "./errors";
15
27
 
28
+ /**
29
+ * Drift guard for every hand-mirrored shape below. v.object strips keys it
30
+ * does not know, so a schema missing a field silently drops that field on
31
+ * the way into DaemonCore. Each entries object is checked with
32
+ * `satisfies EntriesOf<T>`: adding a field to the schema types without
33
+ * mirroring it here (or mirroring a key that does not exist) fails typecheck.
34
+ */
35
+ type EntriesOf<T> = { [K in keyof T]-?: v.GenericSchema<any, any> };
36
+
16
37
  const NonEmpty = v.pipe(v.string(), v.minLength(1));
17
38
 
18
39
  export const WorkspaceSchema = v.object({
19
40
  repoRoot: NonEmpty,
20
41
  branch: NonEmpty,
21
- });
42
+ } satisfies EntriesOf<WorkspaceKey>);
43
+
44
+ export const ArtifactMetaSchema = v.object({
45
+ cwd: v.optional(v.string()),
46
+ agent: v.optional(v.string()),
47
+ agentSessionId: v.optional(v.string()),
48
+ planPath: v.optional(v.string()),
49
+ pr: v.optional(v.string()),
50
+ herdrPane: v.optional(v.string()),
51
+ title: v.optional(v.string()),
52
+ } satisfies EntriesOf<ArtifactMeta>);
53
+
54
+ export const DiffFileContentsSchema = v.object({
55
+ path: NonEmpty,
56
+ oldContents: v.string(),
57
+ newContents: v.string(),
58
+ status: v.picklist(["added", "modified", "deleted"]),
59
+ } satisfies EntriesOf<DiffFileContents>);
22
60
 
23
61
  export const ArtifactSchema = v.object({
24
62
  type: v.picklist(["plan", "diff"]),
25
63
  content: v.string(),
26
- meta: v.optional(
27
- v.object({
28
- cwd: v.optional(v.string()),
29
- agent: v.optional(v.string()),
30
- agentSessionId: v.optional(v.string()),
31
- planPath: v.optional(v.string()),
32
- title: v.optional(v.string()),
33
- pr: v.optional(v.string()),
34
- }),
35
- {},
36
- ),
37
- });
64
+ meta: v.optional(ArtifactMetaSchema, {}),
65
+ files: v.optional(v.array(DiffFileContentsSchema)),
66
+ } satisfies EntriesOf<Artifact>);
38
67
 
39
68
  export const AnchorSchema = v.object({
40
69
  quote: v.string(),
@@ -43,19 +72,40 @@ export const AnchorSchema = v.object({
43
72
  blockIndex: v.optional(v.number()),
44
73
  start: v.optional(v.number()),
45
74
  end: v.optional(v.number()),
46
- });
75
+ } satisfies EntriesOf<Anchor>);
47
76
 
77
+ /** Wire annotations arrive without createdAt - the daemon stamps it. */
48
78
  export const AnnotationSchema = v.object({
49
79
  id: NonEmpty,
50
- /** Open kind set (#2): built-ins are comment and suggestion. */
80
+ /** Open kind set: the built-in is comment. */
51
81
  kind: NonEmpty,
52
82
  anchor: AnchorSchema,
53
83
  body: v.string(),
54
84
  orphan: v.optional(v.boolean()),
55
- });
85
+ author: v.optional(v.string()),
86
+ resolution: v.optional(
87
+ v.object({
88
+ revision: v.number(),
89
+ source: v.picklist(["agent", "drift"]),
90
+ }),
91
+ ),
92
+ } satisfies EntriesOf<Omit<Annotation, "createdAt">>);
56
93
 
57
94
  const SessionId = NonEmpty;
58
95
 
96
+ /** A stored annotation: the wire shape plus the daemon-stamped createdAt. */
97
+ export const FullAnnotationSchema = v.object({
98
+ ...AnnotationSchema.entries,
99
+ createdAt: v.string(),
100
+ } satisfies EntriesOf<Annotation>);
101
+
102
+ export const IdentitySchema = v.object({
103
+ id: NonEmpty,
104
+ provider: v.literal("ssh"),
105
+ name: v.optional(v.string()),
106
+ handle: v.optional(v.string()),
107
+ } satisfies EntriesOf<Identity>);
108
+
59
109
  export const Params = {
60
110
  "session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
61
111
  "session.get": v.object({ id: SessionId }),
@@ -70,20 +120,44 @@ export const Params = {
70
120
  "session.wait": v.object({
71
121
  id: SessionId,
72
122
  // clamped: a negative or absurd timeout is a client bug, not a daemon one
73
- timeoutMs: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(24 * 60 * 60 * 1000)), 60_000),
123
+ timeoutMs: v.optional(
124
+ v.pipe(v.number(), v.minValue(0), v.maxValue(24 * 60 * 60 * 1000)),
125
+ 60_000,
126
+ ),
127
+ }),
128
+ "session.annotate": v.object({
129
+ id: SessionId,
130
+ annotation: AnnotationSchema,
131
+ authorName: v.optional(v.string()),
74
132
  }),
75
- "session.annotate": v.object({ id: SessionId, annotation: AnnotationSchema }),
76
133
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
77
134
  "session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
135
+ "session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
136
+ "session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
137
+ "session.delete": v.object({ id: SessionId }),
138
+ "session.mergeShared": v.object({
139
+ id: SessionId,
140
+ annotations: v.array(FullAnnotationSchema),
141
+ participants: v.optional(v.array(IdentitySchema)),
142
+ }),
78
143
  "session.resolve": v.object({
79
144
  id: SessionId,
80
145
  verdictKind: v.picklist(["comment", "approve", "request_changes"]),
81
146
  summary: v.optional(v.string(), ""),
82
147
  }),
83
- "session.submitRevision": v.object({ id: SessionId, content: v.string() }),
148
+ "session.submitRevision": v.object({
149
+ id: SessionId,
150
+ content: v.string(),
151
+ /** Annotation ids the agent acted on; each is marked addressed. */
152
+ addressedAnnotationIds: v.optional(v.array(NonEmpty), []),
153
+ }),
84
154
  "events.subscribe": v.object({}),
85
155
  "daemon.ping": v.object({}),
156
+ "daemon.hello": v.object({ role: v.picklist(["owner", "collaborator", "agent"]) }),
86
157
  "daemon.shutdown": v.object({}),
158
+ // herdr adapter scratch: the review's opened tab, kept off the session record.
159
+ "herdr.getTab": v.object({ id: SessionId }),
160
+ "herdr.setTab": v.object({ id: SessionId, tabId: NonEmpty, paneId: NonEmpty }),
87
161
  } as const;
88
162
 
89
163
  export type MethodName = keyof typeof Params;
@@ -93,41 +167,59 @@ export function isKnownMethod(method: string): method is MethodName {
93
167
  }
94
168
 
95
169
  /** Validate params for a method, or throw a DaemonError the client can read. */
96
- export function parseParams<M extends MethodName>(method: M, params: unknown): v.InferOutput<(typeof Params)[M]> {
170
+ export function parseParams<M extends MethodName>(
171
+ method: M,
172
+ params: unknown,
173
+ ): v.InferOutput<(typeof Params)[M]> {
97
174
  const result = v.safeParse(Params[method], params ?? {});
98
175
  if (!result.success) {
99
176
  const issue = result.issues[0]!;
100
- const path = issue.path?.map((p) => String(p.key)).join(".") ?? "";
101
- throw new DaemonError("invalid_params", `${method}: ${path ? path + " - " : ""}${issue.message}`);
177
+ const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
178
+ throw new DaemonError(
179
+ "invalid_params",
180
+ `${method}: ${path ? path + " - " : ""}${issue.message}`,
181
+ );
102
182
  }
103
183
  return result.output;
104
184
  }
105
185
 
186
+ export const RevisionSchema = v.object({
187
+ revision: v.number(),
188
+ content: v.string(),
189
+ submittedAt: v.string(),
190
+ } satisfies EntriesOf<Revision>);
191
+
192
+ export const VerdictSchema = v.object({
193
+ kind: v.picklist(["comment", "approve", "request_changes"]),
194
+ summary: v.string(),
195
+ feedback: v.string(),
196
+ resolvedAt: v.string(),
197
+ } satisfies EntriesOf<Verdict>);
198
+
106
199
  /** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
107
200
  export const SessionRecordSchema = v.object({
108
201
  schemaVersion: v.literal(SCHEMA_VERSION),
109
202
  id: NonEmpty,
110
203
  workspace: WorkspaceSchema,
111
204
  artifact: ArtifactSchema,
112
- revisions: v.array(v.object({ revision: v.number(), content: v.string(), submittedAt: v.string() })),
113
- annotations: v.array(v.object({ ...AnnotationSchema.entries, createdAt: v.string() })),
205
+ revisions: v.array(RevisionSchema),
206
+ annotations: v.array(FullAnnotationSchema),
114
207
  workingCopy: v.optional(v.string()),
115
- verdict: v.nullable(
116
- v.object({
117
- kind: v.picklist(["comment", "approve", "request_changes"]),
118
- summary: v.string(),
119
- feedback: v.string(),
120
- resolvedAt: v.string(),
121
- }),
122
- ),
208
+ viewedPaths: v.optional(v.array(v.string())),
209
+ verdict: v.nullable(VerdictSchema),
123
210
  status: v.picklist(["pending", "resolved"]),
124
211
  createdAt: v.string(),
125
- });
212
+ shareId: v.optional(v.string()),
213
+ owner: v.optional(v.string()),
214
+ participants: v.optional(v.array(IdentitySchema)),
215
+ } satisfies EntriesOf<ReviewSession>);
126
216
 
127
- export function validateSessionRecord(raw: unknown): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
217
+ export function validateSessionRecord(
218
+ raw: unknown,
219
+ ): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
128
220
  const result = v.safeParse(SessionRecordSchema, raw);
129
221
  if (result.success) return { ok: true, value: result.output };
130
222
  const issue = result.issues[0]!;
131
- const path = issue.path?.map((p) => String(p.key)).join(".") ?? "";
223
+ const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
132
224
  return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
133
225
  }