@cueloop/daemon 0.1.0-alpha.11 → 0.1.0-alpha.111

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.
Files changed (85) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +44 -22
  4. package/src/agent-tools.ts +104 -0
  5. package/src/agent-turn.test.ts +24 -0
  6. package/src/agent-turn.ts +34 -0
  7. package/src/api.test.ts +1081 -65
  8. package/src/api.ts +1483 -90
  9. package/src/capabilities.test.ts +57 -0
  10. package/src/capabilities.ts +95 -0
  11. package/src/client-errors.ts +24 -0
  12. package/src/client-reliability.test.ts +191 -0
  13. package/src/client.test.ts +37 -0
  14. package/src/client.ts +844 -95
  15. package/src/curate.ts +111 -0
  16. package/src/daemon-sdk-effect.ts +113 -0
  17. package/src/daemon-sdk.test.ts +306 -0
  18. package/src/daemon-sdk.ts +405 -0
  19. package/src/daemon-version.test.ts +142 -0
  20. package/src/diff-hot-reload.test.ts +423 -0
  21. package/src/diff-watcher.test.ts +165 -0
  22. package/src/diff-watcher.ts +363 -0
  23. package/src/examples/daemon-sdk.ts +57 -0
  24. package/src/gh.test.ts +59 -0
  25. package/src/gh.ts +78 -0
  26. package/src/ghostty-thread-surface-store.test.ts +49 -0
  27. package/src/ghostty-thread-surface-store.ts +104 -0
  28. package/src/ghostty-thread-surface.test.ts +400 -0
  29. package/src/ghostty-thread-surface.ts +273 -0
  30. package/src/harness-delivery.test.ts +269 -0
  31. package/src/harness-diagnostics.test.ts +26 -0
  32. package/src/harness-diagnostics.ts +31 -0
  33. package/src/harness-handoff.test.ts +725 -0
  34. package/src/harness-handoff.ts +27 -0
  35. package/src/harness-state-store.test.ts +93 -0
  36. package/src/harness-state-store.ts +218 -0
  37. package/src/herdr-split.test.ts +106 -0
  38. package/src/herdr-split.ts +90 -0
  39. package/src/herdr-thread-surface-store.test.ts +81 -0
  40. package/src/herdr-thread-surface-store.ts +66 -0
  41. package/src/herdr-thread-surface.test.ts +465 -0
  42. package/src/herdr-thread-surface.ts +392 -0
  43. package/src/index.ts +8 -9
  44. package/src/interruptible-wait.test.ts +127 -0
  45. package/src/interruptible-wait.ts +69 -0
  46. package/src/jj-working-tree.ts +140 -0
  47. package/src/main.ts +5 -1
  48. package/src/operation-receipts.test.ts +111 -0
  49. package/src/operation-receipts.ts +104 -0
  50. package/src/paths.ts +49 -0
  51. package/src/pr-poller.test.ts +102 -0
  52. package/src/pr-poller.ts +80 -0
  53. package/src/project-files.test.ts +134 -0
  54. package/src/project-files.ts +72 -0
  55. package/src/protocol.ts +82 -7
  56. package/src/retention.test.ts +191 -0
  57. package/src/retention.ts +131 -0
  58. package/src/sdk-identities.ts +35 -0
  59. package/src/server.test.ts +502 -42
  60. package/src/server.ts +660 -88
  61. package/src/share-blob.test.ts +142 -0
  62. package/src/share-blob.ts +57 -0
  63. package/src/shared-agent-protocol.ts +30 -0
  64. package/src/single-instance.test.ts +165 -13
  65. package/src/store.conformance.test.ts +41 -0
  66. package/src/store.jsonl.test.ts +173 -0
  67. package/src/store.ts +278 -34
  68. package/src/testing/store-conformance.ts +192 -0
  69. package/src/thread-agent-validation.ts +141 -0
  70. package/src/thread-agent.test.ts +732 -0
  71. package/src/thread-agent.ts +1171 -0
  72. package/src/thread-review.test.ts +487 -0
  73. package/src/thread-review.ts +394 -0
  74. package/src/thread-subscription.test.ts +177 -0
  75. package/src/thread-subscription.ts +138 -0
  76. package/src/thread-surface-config.test.ts +63 -0
  77. package/src/thread-surface-config.ts +61 -0
  78. package/src/validate.test.ts +290 -37
  79. package/src/validate.ts +427 -33
  80. package/src/vcs-source.test.ts +180 -0
  81. package/src/vcs-source.ts +235 -0
  82. package/src/version.ts +6 -0
  83. package/src/working-tree.ts +154 -0
  84. package/src/review.test.ts +0 -167
  85. package/src/review.ts +0 -174
@@ -0,0 +1,63 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadGhosttyThreadSurface, loadHerdrThreadSurface } from "./thread-surface-config";
6
+
7
+ let dir: string;
8
+ let userPath: string;
9
+
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-config-"));
12
+ userPath = join(dir, "user.toml");
13
+ });
14
+
15
+ describe("personal Ghostty Thread surface config", () => {
16
+ test("defaults to tab and accepts pane, window, or none", () => {
17
+ expect(loadGhosttyThreadSurface(userPath)).toBe("tab");
18
+
19
+ for (const mode of ["pane", "window", "none"] as const) {
20
+ writeFileSync(userPath, `[integrations.ghostty]\nthread_surface = "${mode}"\n`);
21
+ expect(loadGhosttyThreadSurface(userPath)).toBe(mode);
22
+ }
23
+
24
+ writeFileSync(userPath, '[integrations.ghostty]\nthread_surface = "invalid"\n');
25
+ expect(loadGhosttyThreadSurface(userPath)).toBe("tab");
26
+ });
27
+
28
+ test("ignores repository config", () => {
29
+ const repoConfig = join(dir, "repo", ".cueloop", "config.toml");
30
+
31
+ mkdirSync(join(dir, "repo", ".cueloop"), { recursive: true });
32
+ writeFileSync(userPath, '[integrations.ghostty]\nthread_surface = "window"\n');
33
+ writeFileSync(repoConfig, '[integrations.ghostty]\nthread_surface = "none"\n');
34
+
35
+ expect(loadGhosttyThreadSurface(userPath)).toBe("window");
36
+ });
37
+ });
38
+
39
+ afterEach(() => {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ });
42
+
43
+ describe("personal Herdr Thread surface config", () => {
44
+ test("defaults to tab and accepts pane or none", () => {
45
+ expect(loadHerdrThreadSurface(userPath)).toBe("tab");
46
+ writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "pane"\n');
47
+ expect(loadHerdrThreadSurface(userPath)).toBe("pane");
48
+ writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "none"\n');
49
+ expect(loadHerdrThreadSurface(userPath)).toBe("none");
50
+ writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "invalid"\n');
51
+ expect(loadHerdrThreadSurface(userPath)).toBe("tab");
52
+ });
53
+
54
+ test("never reads a repository's terminal automation setting", () => {
55
+ const repoConfig = join(dir, "repo", ".cueloop", "config.toml");
56
+
57
+ mkdirSync(join(dir, "repo", ".cueloop"), { recursive: true });
58
+ writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "pane"\n');
59
+ writeFileSync(repoConfig, '[integrations.herdr]\nthread_surface = "none"\n');
60
+
61
+ expect(loadHerdrThreadSurface(userPath)).toBe("pane");
62
+ });
63
+ });
@@ -0,0 +1,61 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { parse as parseToml } from "smol-toml";
5
+ import * as v from "valibot";
6
+
7
+ /** Placement of a cueloop Thread inside Herdr. */
8
+ export type HerdrThreadSurface = "tab" | "pane" | "none";
9
+ /** Placement of a cueloop Thread inside Ghostty. */
10
+ export type GhosttyThreadSurface = "tab" | "pane" | "window" | "none";
11
+
12
+ const PersonalThreadSurfaceSchema = v.object({
13
+ integrations: v.optional(
14
+ v.object({
15
+ herdr: v.optional(
16
+ v.object({
17
+ thread_surface: v.fallback(v.optional(v.picklist(["tab", "pane", "none"])), undefined),
18
+ }),
19
+ ),
20
+ ghostty: v.optional(
21
+ v.object({
22
+ thread_surface: v.fallback(
23
+ v.optional(v.picklist(["tab", "pane", "window", "none"])),
24
+ undefined,
25
+ ),
26
+ }),
27
+ ),
28
+ }),
29
+ ),
30
+ });
31
+
32
+ /** Read Herdr placement from the personal TOML file only; invalid values use the tab default. */
33
+ export function loadHerdrThreadSurface(userConfigPath?: string): HerdrThreadSurface {
34
+ return (
35
+ loadPersonalThreadSurfaceConfig(userConfigPath)?.integrations?.herdr?.thread_surface ?? "tab"
36
+ );
37
+ }
38
+
39
+ /** Read Ghostty placement from the personal TOML file only. */
40
+ export function loadGhosttyThreadSurface(userConfigPath?: string): GhosttyThreadSurface {
41
+ return (
42
+ loadPersonalThreadSurfaceConfig(userConfigPath)?.integrations?.ghostty?.thread_surface ?? "tab"
43
+ );
44
+ }
45
+
46
+ function loadPersonalThreadSurfaceConfig(userConfigPath?: string) {
47
+ const path =
48
+ userConfigPath ??
49
+ process.env.CUELOOP_CONFIG ??
50
+ join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
51
+
52
+ if (!existsSync(path)) return null;
53
+
54
+ try {
55
+ const parsed = v.safeParse(PersonalThreadSurfaceSchema, parseToml(readFileSync(path, "utf8")));
56
+
57
+ return parsed.success ? parsed.output : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import * as v from "valibot";
2
3
  import { mkdtempSync, rmSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
@@ -9,23 +10,26 @@ import {
9
10
  AnnotationSchema,
10
11
  ArtifactMetaSchema,
11
12
  ArtifactSchema,
13
+ IdentitySchema,
12
14
  RevisionSchema,
13
- SessionRecordSchema,
14
- VerdictSchema,
15
+ ThreadRecordSchema,
16
+ MessageSchema,
15
17
  WorkspaceSchema,
16
18
  isKnownMethod,
17
19
  parseParams,
18
- validateSessionRecord,
20
+ validateThreadRecord,
19
21
  } from "./validate";
20
22
  import {
23
+ ARTIFACT_TYPES,
21
24
  SCHEMA_VERSION,
22
25
  type Anchor,
23
26
  type Annotation,
24
27
  type Artifact,
25
28
  type ArtifactMeta,
26
- type ReviewSession,
29
+ type Identity,
30
+ type Thread,
27
31
  type Revision,
28
- type Verdict,
32
+ type Message,
29
33
  type WorkspaceKey,
30
34
  } from "@cueloop/schema";
31
35
 
@@ -38,11 +42,14 @@ describe("method allowlist", () => {
38
42
 
39
43
  describe("parseParams", () => {
40
44
  test("accepts a well-formed create and defaults meta", () => {
41
- const p = parseParams("session.create", {
45
+ // Act
46
+ const params = parseParams("session.create", {
42
47
  workspace: { repoRoot: "/repo", branch: "main" },
43
48
  artifact: { type: "plan", content: "# P" },
44
49
  });
45
- expect(p.artifact.meta).toEqual({});
50
+
51
+ // Assert
52
+ expect(params.artifact.meta).toEqual({});
46
53
  });
47
54
 
48
55
  test("rejects a missing workspace with a pathed message", () => {
@@ -51,11 +58,49 @@ describe("parseParams", () => {
51
58
  throw new Error("should have thrown");
52
59
  } catch (err) {
53
60
  expect(err).toBeInstanceOf(DaemonError);
54
- expect((err as DaemonError).code).toBe("invalid_params");
55
- expect((err as DaemonError).message).toContain("session.create: workspace");
61
+
62
+ if (err instanceof DaemonError) {
63
+ expect(err.code).toBe("invalid_params");
64
+ expect(err.message).toContain("session.create: workspace");
65
+ }
56
66
  }
57
67
  });
58
68
 
69
+ test("accepts every artifact type in the schema union", () => {
70
+ for (const type of ARTIFACT_TYPES) {
71
+ const params = parseParams("session.create", {
72
+ workspace: { repoRoot: "/repo", branch: "main" },
73
+ artifact: { type, content: "body" },
74
+ });
75
+
76
+ expect(params.artifact.type).toBe(type);
77
+ }
78
+ });
79
+
80
+ test.each([
81
+ [
82
+ [
83
+ { start: 2, end: 4, quote: "ab" },
84
+ { start: 1, end: 2, quote: "c" },
85
+ ],
86
+ ],
87
+ [
88
+ [
89
+ { start: 1, end: 4, quote: "abc" },
90
+ { start: 3, end: 5, quote: "de" },
91
+ ],
92
+ ],
93
+ [[{ start: 1, end: 4, quote: "ab" }]],
94
+ ])("rejects malformed text Cut provenance", (textCuts) => {
95
+ expect(() =>
96
+ parseParams("session.setWorkingCopy", {
97
+ id: "ses_1",
98
+ workingCopy: "remaining",
99
+ textCuts,
100
+ }),
101
+ ).toThrow("text Cuts must be ordered, non-overlapping, and match their quote lengths");
102
+ });
103
+
59
104
  test("rejects an unknown artifact type", () => {
60
105
  expect(() =>
61
106
  parseParams("session.create", {
@@ -72,21 +117,40 @@ describe("parseParams", () => {
72
117
  test("defaults and clamps the wait timeout", () => {
73
118
  expect(parseParams("session.wait", { id: "ses_1" }).timeoutMs).toBe(60_000);
74
119
  expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: -5 })).toThrow();
75
- expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 })).toThrow();
120
+ expect(() =>
121
+ parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 }),
122
+ ).toThrow();
76
123
  });
77
124
 
78
- test("verdict kinds are closed", () => {
79
- expect(parseParams("session.resolve", { id: "s", verdictKind: "approve" }).summary).toBe("");
80
- expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(/verdictKind/);
125
+ test("message kinds are closed", () => {
126
+ expect(parseParams("session.sendMessage", { id: "s", outcome: "approved" }).summary).toBe("");
127
+ expect(() => parseParams("session.sendMessage", { id: "s", outcome: "lgtm" })).toThrow(
128
+ /outcome/,
129
+ );
81
130
  });
82
131
 
83
132
  test("annotation kinds stay open (extension kinds are allowed)", () => {
84
- const p = parseParams("session.annotate", {
133
+ // Act
134
+ const params = parseParams("session.annotate", {
85
135
  id: "s",
86
136
  annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
87
137
  });
88
- expect(p.annotation.kind).toBe("praise");
89
- expect(p.annotation.anchor.prefix).toBe("");
138
+
139
+ // Assert
140
+ expect(params.annotation.kind).toBe("praise");
141
+ expect(params.annotation.anchor.prefix).toBe("");
142
+ });
143
+
144
+ test("setViewed takes a full path list and rejects non-string entries", () => {
145
+ expect(
146
+ parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] })
147
+ .viewedPaths,
148
+ ).toEqual(["src/a.ts", "src/b.ts"]);
149
+ expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
150
+ expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(
151
+ /viewedPaths/,
152
+ );
153
+ expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
90
154
  });
91
155
 
92
156
  test("null params are treated as empty", () => {
@@ -94,7 +158,7 @@ describe("parseParams", () => {
94
158
  });
95
159
  });
96
160
 
97
- describe("validateSessionRecord", () => {
161
+ describe("validateThreadRecord", () => {
98
162
  const record = {
99
163
  schemaVersion: SCHEMA_VERSION,
100
164
  id: "ses_1",
@@ -102,72 +166,234 @@ describe("validateSessionRecord", () => {
102
166
  artifact: { type: "plan", content: "# P", meta: {} },
103
167
  revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
104
168
  annotations: [],
105
- verdict: null,
169
+ message: null,
106
170
  status: "pending",
107
171
  createdAt: "now",
108
172
  };
109
173
 
110
174
  test("accepts a valid record", () => {
111
- expect(validateSessionRecord(record).ok).toBe(true);
175
+ expect(validateThreadRecord(record).ok).toBe(true);
112
176
  });
113
177
 
114
178
  test("rejects a foreign schema version with a readable reason", () => {
115
- const r = validateSessionRecord({ ...record, schemaVersion: "99" });
116
- expect(r.ok).toBe(false);
117
- if (!r.ok) expect(r.error).toContain("schemaVersion");
179
+ // Act
180
+ const result = validateThreadRecord({ ...record, schemaVersion: "99" });
181
+
182
+ // Assert
183
+ expect(result.ok).toBe(false);
184
+
185
+ if (!result.ok) expect(result.error).toContain("schemaVersion");
118
186
  });
119
187
 
120
188
  test("rejects a structurally broken record", () => {
121
- const r = validateSessionRecord({ ...record, revisions: "nope" });
122
- expect(r.ok).toBe(false);
189
+ // Act
190
+ const result = validateThreadRecord({ ...record, revisions: "nope" });
191
+
192
+ // Assert
193
+ expect(result.ok).toBe(false);
123
194
  });
124
195
  });
125
196
 
126
197
  /**
127
- * Wire pins (#70): every schema's entries must cover exactly the keys of the
198
+ * Wire pins: every schema's entries must cover exactly the keys of the
128
199
  * schema type it mirrors. Runtime complement to the compile-time EntriesOf
129
200
  * check - the samples are fully populated and typed, so a new field in
130
201
  * @cueloop/schema shows up here too.
131
202
  */
132
203
  describe("wire pins", () => {
133
- const keys = (o: object) => Object.keys(o).sort();
204
+ const keys = <Subject extends object>(subject: Subject) => Object.keys(subject).sort();
134
205
  const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
135
206
 
136
207
  const fullMeta: Required<ArtifactMeta> = {
208
+ workflow: "plan",
137
209
  cwd: "/repo",
210
+ vcs: "jj",
211
+ vcsChangeId: "change",
212
+ vcsRevisionId: "revision",
138
213
  agent: "claude-code",
139
214
  agentSessionId: "sess-1",
140
215
  planPath: "/repo/plan.md",
216
+ prototypePath: "/repo/proto.html",
141
217
  pr: "org/repo#1",
218
+ prBrief: "# PR",
219
+ prBaseSha: "base",
220
+ prHeadSha: "head",
221
+ prRefreshBaseSha: "next-base",
222
+ prRefreshHeadSha: "next",
223
+ prUrl: "https://github.com/org/repo/pull/1",
142
224
  herdrPane: "%7",
143
225
  title: "Plan",
226
+ workbench: false,
227
+ snapshot: false,
228
+ };
229
+ const fullAnchor: Required<Anchor> = {
230
+ quote: "q",
231
+ prefix: "p",
232
+ suffix: "s",
233
+ blockIndex: 0,
234
+ endBlockIndex: 0,
235
+ start: 0,
236
+ end: 1,
237
+ selector: "div.card",
144
238
  };
145
- const fullAnchor: Required<Anchor> = { quote: "q", prefix: "p", suffix: "s", blockIndex: 0, start: 0, end: 1 };
146
239
  const fullAnnotation: Required<Annotation> = {
147
240
  id: "a1",
148
241
  kind: "comment",
149
242
  anchor: fullAnchor,
243
+ target: { kind: "file", path: "src/x.ts", rev: "worktree" },
150
244
  body: "b",
151
245
  orphan: false,
246
+ author: "SHA256:abc",
247
+ reviewComment: {
248
+ severity: "p1",
249
+ title: "Finding",
250
+ path: "src/x.ts",
251
+ line: 1,
252
+ startLine: 1,
253
+ startAnchor: fullAnchor,
254
+ side: "RIGHT",
255
+ suggestion: "replacement",
256
+ prompt: "fix it",
257
+ },
258
+ replyTo: "a0",
259
+ resolution: { revision: 2, source: "agent" },
152
260
  createdAt: "now",
153
261
  };
154
- const fullArtifact: Required<Artifact> = { type: "plan", content: "# P", meta: fullMeta };
155
- const fullVerdict: Required<Verdict> = { kind: "approve", summary: "", feedback: "", resolvedAt: "now" };
156
- const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
157
- const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
158
- const fullSession: Required<ReviewSession> = {
262
+ const fullArtifact: Required<Artifact> = {
263
+ type: "plan",
264
+ content: "# P",
265
+ meta: fullMeta,
266
+ files: [{ path: "src/a.ts", oldContents: "old\n", newContents: "new\n", status: "modified" }],
267
+ };
268
+ const fullMessage: Required<Message> = {
269
+ id: "msg_1",
270
+ outcome: "approved",
271
+ summary: "",
272
+ body: "",
273
+ annotations: [fullAnnotation],
274
+ sentAt: "now",
275
+ };
276
+ const fullRevision: Required<Revision> = {
277
+ revision: 1,
278
+ content: "# P",
279
+ submittedAt: "now",
280
+ source: { vcs: "jj", changeId: "change", revisionId: "revision" },
281
+ files: [{ path: "a.ts", oldContents: "a", newContents: "b", status: "modified" }],
282
+ };
283
+ const fullWorkspace: Required<WorkspaceKey> = {
284
+ repoRoot: "/repo",
285
+ branch: "main",
286
+ rootCommit: "11e7abbcf507a13ae5e4e7559c4b42dbb52237fe",
287
+ remote: "git@github.com:acme/repo.git",
288
+ };
289
+ const fullIdentity: Required<Identity> = {
290
+ id: "SHA256:abc",
291
+ provider: "ssh",
292
+ name: "Al",
293
+ handle: "abc",
294
+ };
295
+ const fullSession: Required<Thread> = {
296
+ agentSnapshot: {
297
+ threadId: "ses_1",
298
+ phase: { kind: "idle" },
299
+ messages: [],
300
+ comments: [],
301
+ tools: [],
302
+ },
159
303
  schemaVersion: SCHEMA_VERSION,
160
304
  id: "ses_1",
161
305
  workspace: fullWorkspace,
162
306
  artifact: fullArtifact,
163
307
  revisions: [fullRevision],
164
308
  annotations: [fullAnnotation],
165
- workingCopy: "# P edited",
166
- verdict: fullVerdict,
309
+ history: {
310
+ entries: [
311
+ {
312
+ id: "e1",
313
+ parentId: null,
314
+ type: "revision",
315
+ by: "agent",
316
+ content: "# P",
317
+ createdAt: "now",
318
+ },
319
+ { id: "e2", parentId: "e1", type: "comment", annotationId: "a1", createdAt: "now" },
320
+ { id: "e3", parentId: "e2", type: "comment-removed", annotationId: "a1", createdAt: "now" },
321
+ { id: "e4", parentId: "e3", type: "message", message: fullMessage, createdAt: "now" },
322
+ {
323
+ id: "e5",
324
+ parentId: "e4",
325
+ type: "branch-summary",
326
+ text: "tried a thing",
327
+ abandoned: ["e3"],
328
+ createdAt: "now",
329
+ },
330
+ ],
331
+ tips: { main: "e5" },
332
+ branch: "main",
333
+ labels: { e1: "start" },
334
+ },
335
+ curation: [{ path: "src/a.ts", hunkIndex: 0, changeIndex: 1 }],
336
+ shelvedAnnotations: [fullAnnotation],
337
+ parentSessionId: "ses_0",
338
+ workingCopy: "# ",
339
+ textCuts: [{ start: 2, end: 3, quote: "P" }],
340
+ viewedPaths: ["src/a.ts"],
341
+ messageOperations: [{ operationId: "operation", fingerprint: "hash", result: fullMessage }],
342
+ message: fullMessage,
167
343
  status: "pending",
168
344
  createdAt: "now",
345
+ shares: [
346
+ {
347
+ id: "p_abc123xy",
348
+ name: "review link",
349
+ requireAuth: true,
350
+ allowlist: ["octocat"],
351
+ owner: "SHA256:owner",
352
+ shareBranch: "main",
353
+ },
354
+ ],
355
+ participants: [fullIdentity],
169
356
  };
170
357
 
358
+ test("a history whose tree is broken is refused even when every entry is well-formed", () => {
359
+ // Arrange: a parent cycle
360
+ const record = {
361
+ ...fullSession,
362
+ history: {
363
+ entries: [
364
+ { id: "a", parentId: "b", type: "revision", by: "agent", content: "x", createdAt: "now" },
365
+ { id: "b", parentId: "a", type: "comment", annotationId: "a1", createdAt: "now" },
366
+ ],
367
+ tips: { main: "b" },
368
+ branch: "main",
369
+ labels: {},
370
+ },
371
+ };
372
+
373
+ // Act
374
+ const parsed = validateThreadRecord(record);
375
+
376
+ // Assert
377
+ expect(parsed.ok).toBe(false);
378
+
379
+ if (!parsed.ok) expect(parsed.error).toContain("history:");
380
+ });
381
+
382
+ test("a persisted Thread with stale text Cut provenance is refused", () => {
383
+ const result = validateThreadRecord({
384
+ ...fullSession,
385
+ workingCopy: "# P",
386
+ });
387
+
388
+ expect(result.ok).toBe(false);
389
+
390
+ if (!result.ok) {
391
+ expect(result.error).toContain(
392
+ "text Cuts do not match the submitted artifact and working copy",
393
+ );
394
+ }
395
+ });
396
+
171
397
  test("schema key sets match the schema types", () => {
172
398
  expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
173
399
  expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
@@ -175,27 +401,54 @@ describe("wire pins", () => {
175
401
  expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
176
402
  // wire annotations arrive without createdAt; the daemon stamps it
177
403
  const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
404
+
178
405
  expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
179
406
  expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
180
- expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
181
- expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
407
+ expect(entryKeys(MessageSchema)).toEqual(keys(fullMessage));
408
+ expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
409
+ expect(entryKeys(ThreadRecordSchema)).toEqual(keys(fullSession));
182
410
  // persisted annotations carry the stamped createdAt
183
- const stored = SessionRecordSchema.entries.annotations.item;
411
+ const stored = ThreadRecordSchema.entries.annotations.item;
412
+
184
413
  expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
185
414
  });
186
415
 
187
416
  test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
188
417
  const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
418
+
189
419
  try {
420
+ // Arrange
190
421
  const params = parseParams("session.create", {
191
422
  workspace: fullWorkspace,
192
423
  artifact: { type: "plan", content: "# P", meta: fullMeta },
193
424
  });
194
425
  const core = new DaemonCore(home);
426
+
427
+ // Act
195
428
  const created = core.sessionCreate(params);
429
+
430
+ // Assert
196
431
  expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
197
432
  } finally {
198
433
  rmSync(home, { recursive: true, force: true });
199
434
  }
200
435
  });
201
436
  });
437
+
438
+ describe("IdentitySchema provider", () => {
439
+ test("accepts a verified github identity", () => {
440
+ const parsed = v.safeParse(IdentitySchema, {
441
+ id: "SHA256:abc",
442
+ provider: "github",
443
+ name: "markus",
444
+ });
445
+
446
+ expect(parsed.success).toBe(true);
447
+ });
448
+
449
+ test("rejects an unknown provider", () => {
450
+ const parsed = v.safeParse(IdentitySchema, { id: "SHA256:abc", provider: "email" });
451
+
452
+ expect(parsed.success).toBe(false);
453
+ });
454
+ });