@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.
- package/package.json +4 -2
- package/src/api.ts +181 -24
- package/src/client.ts +16 -5
- package/src/diff-hot-reload.test.ts +128 -0
- package/src/diff-watcher.ts +22 -3
- package/src/jj-working-tree.ts +131 -0
- package/src/server.ts +6 -2
- package/src/thread-review.test.ts +90 -1
- package/src/thread-review.ts +85 -9
- package/src/validate.test.ts +10 -1
- package/src/validate.ts +21 -1
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +228 -0
|
@@ -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
|
-
|
|
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, {
|
package/src/thread-review.ts
CHANGED
|
@@ -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
|
|
43
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
package/src/validate.test.ts
CHANGED
|
@@ -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> = {
|
|
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({
|