@cueloop/daemon 0.1.0-alpha.89 → 0.1.0-alpha.90

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.
@@ -0,0 +1,131 @@
1
+ /** Native Jujutsu capture pinned to one commit after its working-copy snapshot. */
2
+
3
+ import type { DiffFileContents, DiffFileStatus } from "@cueloop/schema";
4
+ import type { VcsAdapter, VcsDiffSnapshot } from "@cueloop/extension-api";
5
+
6
+ async function jj(args: string[], cwd: string): Promise<string | null> {
7
+ try {
8
+ const child = Bun.spawn(["jj", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
9
+ const output = await new Response(child.stdout).text();
10
+
11
+ return (await child.exited) === 0 ? output : null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ const JJ_DIFF_FILES_TEMPLATE = 'status_char ++ "\\x00" ++ path.display() ++ "\\x00"';
18
+ const JJ_FILE_LIST_TEMPLATE = 'path.display() ++ "\\x00"';
19
+
20
+ function jjSummary(text: string): { path: string; status: DiffFileStatus; curatable: boolean }[] {
21
+ const tokens = text.split("\0");
22
+ const changes: { path: string; status: DiffFileStatus; curatable: boolean }[] = [];
23
+
24
+ for (let index = 0; index + 1 < tokens.length; index += 2) {
25
+ const code = tokens[index];
26
+ const path = tokens[index + 1];
27
+
28
+ if (!code || !path) continue;
29
+ changes.push({
30
+ path,
31
+ status: code === "A" ? "added" : code === "D" ? "deleted" : "modified",
32
+ curatable: code === "A" || code === "M" || code === "D",
33
+ });
34
+ }
35
+
36
+ return changes;
37
+ }
38
+
39
+ function isBinary(contents: string): boolean {
40
+ return contents.includes("\0");
41
+ }
42
+
43
+ async function jjFileContents(
44
+ root: string,
45
+ revision: string,
46
+ path: string,
47
+ ): Promise<string | null> {
48
+ return jj(["--ignore-working-copy", "file", "show", "-r", revision, "--", path], root);
49
+ }
50
+
51
+ async function captureJjRevision(repoRoot: string, revset: string): Promise<VcsDiffSnapshot> {
52
+ const identity = await jj(
53
+ [
54
+ "log",
55
+ "-r",
56
+ revset,
57
+ "-T",
58
+ 'change_id ++ "\\n" ++ commit_id ++ "\\n" ++ parents.map(|c| c.commit_id()).join(",") ++ "\\n"',
59
+ "--no-graph",
60
+ ],
61
+ repoRoot,
62
+ );
63
+
64
+ if (!identity) throw new Error(`Jujutsu diff capture failed: could not resolve ${revset}`);
65
+ const lines = identity.trim().split("\n");
66
+
67
+ if (lines.length !== 3) throw new Error(`Jujutsu change is ambiguous: ${revset}`);
68
+ const [changeId, revisionId, parents] = lines;
69
+
70
+ if (!changeId || !revisionId) throw new Error("Jujutsu diff capture failed: invalid identity");
71
+ const [patch, summary] = await Promise.all([
72
+ jj(["--ignore-working-copy", "diff", "--git", "-r", revisionId], repoRoot),
73
+ jj(["--ignore-working-copy", "diff", "-T", JJ_DIFF_FILES_TEMPLATE, "-r", revisionId], repoRoot),
74
+ ]);
75
+
76
+ if (patch === null || summary === null)
77
+ throw new Error("Jujutsu diff capture failed: could not read pinned revision");
78
+ const files: DiffFileContents[] = [];
79
+ const parent = parents?.split(",")[0];
80
+
81
+ if (parent && !parents?.includes(",")) {
82
+ for (const change of jjSummary(summary)) {
83
+ if (!change.curatable) continue;
84
+ const oldContents =
85
+ change.status === "added" ? "" : await jjFileContents(repoRoot, parent, change.path);
86
+ const newContents =
87
+ change.status === "deleted" ? "" : await jjFileContents(repoRoot, revisionId, change.path);
88
+
89
+ if (oldContents === null || newContents === null) continue;
90
+ if (isBinary(oldContents) || isBinary(newContents)) continue;
91
+ files.push({ path: change.path, oldContents, newContents, status: change.status });
92
+ }
93
+ }
94
+
95
+ return { patch: patch.replace(/\n+$/, ""), files, source: { changeId, revisionId } };
96
+ }
97
+
98
+ /** Built-in jj adapter uses native change and commit IDs in a colocated checkout. */
99
+ export const jjVcsAdapter: VcsAdapter = {
100
+ apiVersion: 1,
101
+ id: "jj",
102
+ async detect(cwd) {
103
+ const root = await jj(["--ignore-working-copy", "root"], cwd);
104
+
105
+ return root?.trim() ?? null;
106
+ },
107
+ captureWorkingDiff(repoRoot) {
108
+ return captureJjRevision(repoRoot, "@");
109
+ },
110
+ async captureChange(repoRoot, changeId) {
111
+ if (!/^[a-z]{32}$/.test(changeId)) throw new Error(`Jujutsu change ID is invalid: ${changeId}`);
112
+ if ((await jj(["status"], repoRoot)) === null)
113
+ throw new Error("Jujutsu diff capture failed: could not snapshot the working copy");
114
+
115
+ return captureJjRevision(repoRoot, changeId);
116
+ },
117
+ async listChanges(repoRoot) {
118
+ const summary = await jj(["diff", "-T", JJ_DIFF_FILES_TEMPLATE, "-r", "@"], repoRoot);
119
+
120
+ if (summary === null) throw new Error("Jujutsu changes list failed");
121
+
122
+ return jjSummary(summary).map(({ path, status }) => ({ path, status }));
123
+ },
124
+ async listFiles(repoRoot) {
125
+ const files = await jj(["file", "list", "-T", JJ_FILE_LIST_TEMPLATE, "-r", "@"], repoRoot);
126
+
127
+ if (files === null) throw new Error("Jujutsu project files failed");
128
+
129
+ return files.split("\0").filter(Boolean).toSorted().slice(0, 5000);
130
+ },
131
+ };
package/src/server.ts CHANGED
@@ -450,8 +450,11 @@ export class DaemonServer {
450
450
  },
451
451
  "repo.changes": (_connection, request) =>
452
452
  this.core.repoChanges(parseParams("repo.changes", request.params).cwd),
453
- "repo.diff": (_connection, request) =>
454
- this.core.repoDiff(parseParams("repo.diff", request.params).cwd),
453
+ "repo.diff": (_connection, request) => {
454
+ const params = parseParams("repo.diff", request.params);
455
+
456
+ return this.core.repoDiff(params.cwd, params.vcs);
457
+ },
455
458
  "session.workbench": (_connection, request) =>
456
459
  this.core.workbenchSession(parseParams("session.workbench", request.params).cwd),
457
460
  "session.refreshDiff": (_connection, request) => {
@@ -535,6 +538,7 @@ export class DaemonServer {
535
538
  params.content,
536
539
  params.addressedAnnotationIds,
537
540
  params.files,
541
+ params.source,
538
542
  );
539
543
  },
540
544
  "herdr.getThreadSurface": (_connection, request) =>
@@ -1,13 +1,14 @@
1
1
  /** The shared review core against a real DaemonServer in a temp home: workspace resolution, title derivation, open-or-revise by agentSessionId, and both awaitMessage shapes (one long-poll and the chunked loop with progress and abort). */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
- import { mkdtempSync, rmSync } from "node:fs";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import type { Thread } from "@cueloop/schema";
8
8
  import { DaemonServer } from "./server";
9
9
  import { DaemonClient } from "./client";
10
10
  import { awaitResolve, openReview, resolveWorkspace } from "./thread-review";
11
+ import { VcsSourceManager } from "./vcs-source";
11
12
 
12
13
  const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
13
14
 
@@ -66,6 +67,94 @@ describe("resolveWorkspace", () => {
66
67
  });
67
68
 
68
69
  describe("openReview", () => {
70
+ test("a submitted JJ diff follows the same change through a rewrite", async () => {
71
+ const repo = mkdtempSync(join(tmpdir(), "cueloop-review-jj-"));
72
+
73
+ try {
74
+ sh(["git", "init", "-q", "-b", "main"], repo);
75
+ sh(["git", "config", "user.email", "t@t"], repo);
76
+ sh(["git", "config", "user.name", "t"], repo);
77
+ writeFileSync(join(repo, "a.txt"), "before\n");
78
+ sh(["git", "add", "a.txt"], repo);
79
+ sh(["git", "commit", "-qm", "initial"], repo);
80
+ sh(["jj", "git", "init", "--colocate", repo], repo);
81
+ writeFileSync(join(repo, "a.txt"), "first\n");
82
+ const sources = new VcsSourceManager(join(home, "vcs-config.toml"));
83
+ const firstDiff = await sources.capture(repo, "jj");
84
+ const first = await openReview(client, {
85
+ type: "diff",
86
+ content: firstDiff.patch,
87
+ cwd: repo,
88
+ vcs: "jj",
89
+ agent: "codex",
90
+ agentSessionId: "one-agent-session",
91
+ });
92
+
93
+ writeFileSync(join(repo, "a.txt"), "second\n");
94
+ const secondDiff = await sources.capture(repo, "jj");
95
+ const second = await openReview(client, {
96
+ type: "diff",
97
+ content: secondDiff.patch,
98
+ cwd: repo,
99
+ vcs: "jj",
100
+ agent: "codex",
101
+ agentSessionId: "one-agent-session",
102
+ });
103
+
104
+ expect(first.session.artifact.meta.vcs).toBe("jj");
105
+ expect(first.session.artifact.files?.[0]?.newContents).toBe("first\n");
106
+ expect(first.session.artifact.meta.vcsChangeId).toBe(
107
+ second.session.artifact.meta.vcsChangeId,
108
+ );
109
+ expect(second.id).toBe(first.id);
110
+ expect(second.session.revisions.map((revision) => revision.content)).toEqual([
111
+ firstDiff.patch,
112
+ secondDiff.patch,
113
+ ]);
114
+ const unmatched = await openReview(client, {
115
+ type: "diff",
116
+ content: "diff --git a/elsewhere b/elsewhere\n",
117
+ cwd: repo,
118
+ vcs: "jj",
119
+ agent: "codex",
120
+ agentSessionId: "one-agent-session",
121
+ });
122
+
123
+ expect(unmatched.id).toBe(first.id);
124
+ expect(unmatched.session.artifact.files).toEqual([]);
125
+ expect(unmatched.session.artifact.meta.vcsRevisionId).toBeUndefined();
126
+ sh(["jj", "new", "-m", "next change"], repo);
127
+ writeFileSync(join(repo, "b.txt"), "third\n");
128
+ const thirdDiff = await sources.capture(repo, "jj");
129
+ const third = await openReview(client, {
130
+ type: "diff",
131
+ content: thirdDiff.patch,
132
+ cwd: repo,
133
+ vcs: "jj",
134
+ agent: "codex",
135
+ agentSessionId: "one-agent-session",
136
+ });
137
+
138
+ expect(third.id).not.toBe(first.id);
139
+ const unmatchedAfterTwoChanges = await openReview(client, {
140
+ type: "diff",
141
+ content: "diff --git a/unmatched b/unmatched\n",
142
+ cwd: repo,
143
+ vcs: "jj",
144
+ agent: "codex",
145
+ agentSessionId: "one-agent-session",
146
+ });
147
+
148
+ expect(unmatchedAfterTwoChanges.id).not.toBe(first.id);
149
+ expect(unmatchedAfterTwoChanges.id).not.toBe(third.id);
150
+ expect((await client.sessionGet(third.id)).artifact.meta.vcsChangeId).toBe(
151
+ third.session.artifact.meta.vcsChangeId,
152
+ );
153
+ } finally {
154
+ rmSync(repo, { recursive: true, force: true });
155
+ }
156
+ });
157
+
69
158
  test("shapes the artifact: agent, derived title, cwd", async () => {
70
159
  // Act
71
160
  const review = await openReview(client, {
@@ -5,6 +5,7 @@ import {
5
5
  type Artifact,
6
6
  type ArtifactType,
7
7
  type DiffFileContents,
8
+ type DiffSource,
8
9
  type Thread,
9
10
  type WorkspaceKey,
10
11
  type WorkflowKind,
@@ -13,6 +14,7 @@ import { execFile } from "node:child_process";
13
14
  import { promisify } from "node:util";
14
15
  import { messageResponse } from "./api";
15
16
  import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
17
+ import { VcsSourceManager } from "./vcs-source";
16
18
 
17
19
  // Adapters and CLI primitives reach the message mapping through this module too,
18
20
  // so a session obtained outside a ReviewHandle maps the same way.
@@ -28,6 +30,16 @@ async function git(args: string[], cwd: string): Promise<string | null> {
28
30
  }
29
31
  }
30
32
 
33
+ async function jjRoot(cwd: string): Promise<string | null> {
34
+ try {
35
+ const { stdout } = await promisify(execFile)("jj", ["--ignore-working-copy", "root"], { cwd });
36
+
37
+ return stdout.trim();
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
31
43
  /** The oldest root commit reachable from HEAD; the project key that survives moving or re-cloning the repo. */
32
44
  function earliestRootCommit(revList: string | null): string | undefined {
33
45
  if (!revList) return undefined;
@@ -39,15 +51,22 @@ function earliestRootCommit(revList: string | null): string | undefined {
39
51
 
40
52
  /** Workspace key resolution: repo root, branch, and the project identity (root commit + remote) from the cwd. */
41
53
  export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
42
- const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
43
- const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
54
+ const gitRepoRoot = await git(["rev-parse", "--show-toplevel"], cwd);
55
+ const jjRepoRoot = await jjRoot(cwd);
56
+ const nativeJjRoot =
57
+ jjRepoRoot && (!gitRepoRoot || jjRepoRoot.length > gitRepoRoot.length) ? jjRepoRoot : null;
58
+ const repoRoot = nativeJjRoot ?? gitRepoRoot ?? cwd;
59
+ const branch = nativeJjRoot
60
+ ? "jj"
61
+ : ((await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached");
44
62
  // a shallow clone's oldest commit is the graft boundary, not the true root, so
45
63
  // it would key a different project than a full clone - leave it unset instead
46
- const shallow = (await git(["rev-parse", "--is-shallow-repository"], cwd)) === "true";
64
+ const shallow =
65
+ nativeJjRoot !== null || (await git(["rev-parse", "--is-shallow-repository"], cwd)) === "true";
47
66
  const rootCommit = shallow
48
67
  ? undefined
49
68
  : earliestRootCommit(await git(["rev-list", "--max-parents=0", "--date-order", "HEAD"], cwd));
50
- const remote = await git(["remote", "get-url", "origin"], cwd);
69
+ const remote = nativeJjRoot ? null : await git(["remote", "get-url", "origin"], cwd);
51
70
 
52
71
  const workspace: WorkspaceKey = { repoRoot, branch };
53
72
  // a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
@@ -70,6 +89,10 @@ export interface OpenReviewOptions {
70
89
  content: string;
71
90
  /** Workspace resolution root and meta.cwd; defaults to process.cwd(). */
72
91
  cwd?: string;
92
+ /** Optional explicit VCS selection for a diff that matches the checkout capture. */
93
+ vcs?: string;
94
+ /** The native logical change, set after a captured JJ diff is verified. */
95
+ vcsChangeId?: string;
73
96
  /** Pre-resolved workspace key; skips git resolution when the caller already has it. */
74
97
  workspace?: WorkspaceKey;
75
98
  agent?: string;
@@ -114,6 +137,7 @@ export interface ThreadSessionClient {
114
137
  content: string,
115
138
  addressedAnnotationIds?: string[],
116
139
  files?: DiffFileContents[],
140
+ source?: DiffSource,
117
141
  ): Promise<Thread>;
118
142
  sessionAnnotate(
119
143
  id: string,
@@ -249,7 +273,11 @@ export async function findExistingReview(
249
273
 
250
274
  if (options.agentSessionId === undefined) return undefined;
251
275
 
252
- return (await client.sessionList()).find(
276
+ const isJjDiff = options.type === "diff" && options.vcs === "jj";
277
+ const hasJjProvenance = (candidate: Thread): boolean =>
278
+ candidate.artifact.meta.vcs === "jj" ||
279
+ candidate.revisions.some((revision) => revision.source?.vcs === "jj");
280
+ const candidates = (await client.sessionList()).filter(
253
281
  (candidate) =>
254
282
  candidate.artifact.meta.agentSessionId === options.agentSessionId &&
255
283
  candidate.artifact.meta.agent === options.agent &&
@@ -259,8 +287,47 @@ export async function findExistingReview(
259
287
  (options.workflow ?? (options.pr ? "review" : options.type)) &&
260
288
  candidate.artifact.meta.pr === options.pr &&
261
289
  candidate.workspace.repoRoot === workspace.repoRoot &&
262
- candidate.workspace.branch === workspace.branch,
290
+ (candidate.workspace.branch === workspace.branch || (isJjDiff && hasJjProvenance(candidate))),
263
291
  );
292
+
293
+ if (options.vcsChangeId !== undefined)
294
+ return candidates.find(
295
+ (candidate) => candidate.artifact.meta.vcsChangeId === options.vcsChangeId,
296
+ );
297
+ if (isJjDiff) {
298
+ const jjCandidates = candidates.filter(hasJjProvenance);
299
+
300
+ // An unmatched patch has no verified change ID. Reuse the Thread only when
301
+ // there is exactly one possible JJ change in this agent session.
302
+ return jjCandidates.length === 1 ? jjCandidates[0] : undefined;
303
+ }
304
+
305
+ return candidates.find((candidate) => candidate.artifact.meta.vcsChangeId === undefined);
306
+ }
307
+
308
+ /** Attach VCS provenance only when the submitted patch matches a native capture. */
309
+ async function matchingDiffCapture(
310
+ options: OpenReviewOptions,
311
+ cwd: string,
312
+ ): Promise<{ source?: DiffSource; files?: DiffFileContents[] }> {
313
+ if (options.type !== "diff" || options.pr || !options.content.trim()) return {};
314
+ try {
315
+ const captured = await new VcsSourceManager().capture(cwd, options.vcs);
316
+
317
+ if (captured.patch.replace(/\n+$/, "") !== options.content.replace(/\n+$/, "")) return {};
318
+
319
+ return {
320
+ source: {
321
+ vcs: captured.vcs,
322
+ changeId: captured.source?.changeId,
323
+ revisionId: captured.source?.revisionId,
324
+ },
325
+ files: captured.files,
326
+ };
327
+ } catch {
328
+ // A supplied patch stays reviewable even when the local source is unavailable.
329
+ return {};
330
+ }
264
331
  }
265
332
 
266
333
  /** Open a thread (or revise the agent session's existing one) and hand back the wait surface. */
@@ -270,17 +337,23 @@ export async function openReview(
270
337
  ): Promise<ReviewHandle> {
271
338
  const cwd = options.cwd ?? process.cwd();
272
339
  const workspace = options.workspace ?? (await resolveWorkspace(cwd));
273
- const existing = await findExistingReview(client, options);
340
+ const { source, files: capturedFiles } = await matchingDiffCapture(options, cwd);
341
+ const existing = await findExistingReview(client, {
342
+ ...options,
343
+ vcsChangeId: source?.changeId,
344
+ });
274
345
 
275
346
  if (existing !== undefined) {
276
347
  // PR diffs have no full file contents. An absent local-diff capture clears
277
348
  // old curatable files, while a PR revision must keep curation disabled.
278
- const revisionFiles = options.type !== "diff" || options.pr ? undefined : (options.files ?? []);
349
+ const revisionFiles =
350
+ options.type !== "diff" || options.pr ? undefined : (options.files ?? capturedFiles ?? []);
279
351
  let revised = await client.sessionSubmitRevision(
280
352
  existing.id,
281
353
  options.content,
282
354
  [],
283
355
  revisionFiles,
356
+ source,
284
357
  );
285
358
 
286
359
  if (options.notes?.length) {
@@ -293,7 +366,7 @@ export async function openReview(
293
366
  let session = await client.sessionCreate(workspace, {
294
367
  type: options.type,
295
368
  content: options.content,
296
- files: options.files,
369
+ files: options.files ?? capturedFiles,
297
370
  meta: {
298
371
  workflow: options.workflow,
299
372
  agent: options.agent,
@@ -310,6 +383,9 @@ export async function openReview(
310
383
  options.title ??
311
384
  (isMarkdownArtifact(options.type) ? firstHeading(options.content) : undefined),
312
385
  cwd,
386
+ vcs: source?.vcs,
387
+ vcsChangeId: source?.changeId,
388
+ vcsRevisionId: source?.revisionId,
313
389
  },
314
390
  });
315
391
 
@@ -205,6 +205,9 @@ describe("wire pins", () => {
205
205
  const fullMeta: Required<ArtifactMeta> = {
206
206
  workflow: "plan",
207
207
  cwd: "/repo",
208
+ vcs: "jj",
209
+ vcsChangeId: "change",
210
+ vcsRevisionId: "revision",
208
211
  agent: "claude-code",
209
212
  agentSessionId: "sess-1",
210
213
  planPath: "/repo/plan.md",
@@ -268,7 +271,13 @@ describe("wire pins", () => {
268
271
  annotations: [fullAnnotation],
269
272
  sentAt: "now",
270
273
  };
271
- const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
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
+ };
272
281
  const fullWorkspace: Required<WorkspaceKey> = {
273
282
  repoRoot: "/repo",
274
283
  branch: "main",
package/src/validate.ts CHANGED
@@ -82,6 +82,9 @@ export const WorkspaceSchema = v.object({
82
82
  export const ArtifactMetaSchema = v.object({
83
83
  workflow: v.optional(v.picklist(WORKFLOW_KINDS)),
84
84
  cwd: v.optional(v.string()),
85
+ vcs: v.optional(NonEmpty),
86
+ vcsChangeId: v.optional(NonEmpty),
87
+ vcsRevisionId: v.optional(NonEmpty),
85
88
  agent: v.optional(v.string()),
86
89
  agentSessionId: v.optional(v.string()),
87
90
  planPath: v.optional(v.string()),
@@ -270,7 +273,7 @@ export const Params = {
270
273
  "repo.files": v.object({ cwd: NonEmpty }),
271
274
  "repo.fileContents": v.object({ cwd: NonEmpty, path: NonEmpty }),
272
275
  "repo.changes": v.object({ cwd: NonEmpty }),
273
- "repo.diff": v.object({ cwd: NonEmpty }),
276
+ "repo.diff": v.object({ cwd: NonEmpty, vcs: v.optional(NonEmpty) }),
274
277
  "session.workbench": v.object({ cwd: NonEmpty }),
275
278
  "session.navigate": v.object({
276
279
  id: SessionId,
@@ -321,6 +324,14 @@ export const Params = {
321
324
  id: SessionId,
322
325
  content: v.string(),
323
326
  files: v.optional(v.array(DiffFileContentsSchema)),
327
+ source: v.optional(
328
+ v.object({
329
+ vcs: NonEmpty,
330
+ changeId: v.optional(NonEmpty),
331
+ revisionId: v.optional(NonEmpty),
332
+ baseRevisionId: v.optional(NonEmpty),
333
+ }),
334
+ ),
324
335
  /** Annotation ids the agent acted on; each is marked addressed. */
325
336
  addressedAnnotationIds: v.optional(v.array(NonEmpty), []),
326
337
  }),
@@ -379,6 +390,15 @@ export const RevisionSchema = v.object({
379
390
  revision: v.number(),
380
391
  content: v.string(),
381
392
  submittedAt: v.string(),
393
+ files: v.optional(v.array(DiffFileContentsSchema)),
394
+ source: v.optional(
395
+ v.object({
396
+ vcs: NonEmpty,
397
+ changeId: v.optional(NonEmpty),
398
+ revisionId: v.optional(NonEmpty),
399
+ baseRevisionId: v.optional(NonEmpty),
400
+ }),
401
+ ),
382
402
  } satisfies EntriesOf<Revision>);
383
403
 
384
404
  export const MessageSchema = v.object({