@cueloop/daemon 0.1.0-alpha.10 → 0.1.0-alpha.101

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