@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.4.6
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/AGENTS.md +10 -84
- package/README.md +20 -2
- package/docs/architecture.md +28 -2
- package/package.json +4 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
- package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
- package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
- package/packages/extension/src/bridge-context.ts +7 -0
- package/packages/extension/src/bridge.ts +32 -3
- package/packages/extension/src/model-tracker.ts +35 -1
- package/packages/extension/src/prompt-bus.ts +4 -3
- package/packages/extension/src/session-sync.ts +1 -1
- package/packages/extension/src/vcs-info.ts +184 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
- package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
- package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
- package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
- package/packages/server/src/cli.ts +1 -0
- package/packages/server/src/event-wiring.ts +9 -0
- package/packages/server/src/openspec-tasks.ts +50 -19
- package/packages/server/src/routes/jj-routes.ts +386 -0
- package/packages/server/src/routes/session-routes.ts +12 -3
- package/packages/server/src/server.ts +8 -2
- package/packages/server/src/session-diff.ts +118 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
- package/packages/shared/src/config.ts +14 -0
- package/packages/shared/src/diff-types.ts +17 -0
- package/packages/shared/src/platform/jj.ts +405 -0
- package/packages/shared/src/protocol.ts +14 -0
- package/packages/shared/src/tool-registry/definitions.ts +1 -0
- package/packages/shared/src/types.ts +34 -0
- package/packages/extension/src/git-info.ts +0 -55
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the vcs-regime-aware session-diff dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Per spec scenarios:
|
|
5
|
+
* - "Diff in plain git repo is unchanged" — plain-git path is byte-equivalent
|
|
6
|
+
* - "Diff in a workspace shows all agent commits, not just the last" —
|
|
7
|
+
* non-default workspace selects fork_point(@, trunk()) base
|
|
8
|
+
* - "Untracked file in jj path uses native jj diff output" — no synthetic fallback
|
|
9
|
+
*
|
|
10
|
+
* See change: add-jj-workspace-plugin.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from "vitest";
|
|
13
|
+
import type { JjState } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
14
|
+
import { selectJjDiffBase } from "../session-diff.js";
|
|
15
|
+
|
|
16
|
+
describe("selectJjDiffBase", () => {
|
|
17
|
+
it("returns @- for the default workspace", () => {
|
|
18
|
+
const state: JjState = {
|
|
19
|
+
isJjRepo: true,
|
|
20
|
+
isColocated: true,
|
|
21
|
+
workspaceName: "default",
|
|
22
|
+
};
|
|
23
|
+
expect(selectJjDiffBase(state)).toEqual({ diffBase: "@-", baseLabel: "@-" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns @- when workspaceName is undefined (probe mid-flight)", () => {
|
|
27
|
+
const state: JjState = { isJjRepo: true, isColocated: true };
|
|
28
|
+
expect(selectJjDiffBase(state)).toEqual({ diffBase: "@-", baseLabel: "@-" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns trunk() for non-default workspaces", () => {
|
|
32
|
+
const state: JjState = {
|
|
33
|
+
isJjRepo: true,
|
|
34
|
+
isColocated: true,
|
|
35
|
+
workspaceName: "agent-1",
|
|
36
|
+
};
|
|
37
|
+
// Uses the `..` range form on jj-side (--from <base> --to @) — base
|
|
38
|
+
// is `trunk()` so the diff materializes every agent commit in this
|
|
39
|
+
// workspace. `fork_point()` was avoided because its signature varies
|
|
40
|
+
// across jj versions (single-arg in 0.40+, two-arg in older docs).
|
|
41
|
+
expect(selectJjDiffBase(state)).toEqual({
|
|
42
|
+
diffBase: "trunk()",
|
|
43
|
+
baseLabel: "trunk()",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns trunk() for any workspace name except 'default'", () => {
|
|
48
|
+
for (const name of ["feat-x", "experiment", "ws-2", "shadow-7"]) {
|
|
49
|
+
const result = selectJjDiffBase({
|
|
50
|
+
isJjRepo: true,
|
|
51
|
+
isColocated: true,
|
|
52
|
+
workspaceName: name,
|
|
53
|
+
});
|
|
54
|
+
expect(result.diffBase).toBe("trunk()");
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns @- when called with undefined jjState (defensive)", () => {
|
|
59
|
+
expect(selectJjDiffBase(undefined)).toEqual({ diffBase: "@-", baseLabel: "@-" });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -25,7 +25,7 @@ function makeNoopDeps() {
|
|
|
25
25
|
function makeFakeGateway(): { gateway: PiGateway; broadcasts: ServerToExtensionMessage[] } {
|
|
26
26
|
const broadcasts: ServerToExtensionMessage[] = [];
|
|
27
27
|
const gateway: PiGateway = {
|
|
28
|
-
broadcast(msg) { broadcasts.push(msg); },
|
|
28
|
+
broadcast(msg: ServerToExtensionMessage) { broadcasts.push(msg); },
|
|
29
29
|
sendToSession() { return false; },
|
|
30
30
|
isSessionConnected() { return false; },
|
|
31
31
|
connectionCount() { return 0; },
|
|
@@ -46,7 +46,7 @@ describe("POST /api/restart broadcasts server_restarting", () => {
|
|
|
46
46
|
const fake = makeFakeGateway();
|
|
47
47
|
broadcasts = fake.broadcasts;
|
|
48
48
|
// process.exit is deferred via setTimeout(...,200); silence it for the test
|
|
49
|
-
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
|
|
49
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
|
|
50
50
|
registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
|
|
51
51
|
});
|
|
52
52
|
|
|
@@ -82,7 +82,7 @@ describe("POST /api/shutdown broadcasts server_restarting", () => {
|
|
|
82
82
|
fastify = Fastify();
|
|
83
83
|
const fake = makeFakeGateway();
|
|
84
84
|
broadcasts = fake.broadcasts;
|
|
85
|
-
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
|
|
85
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
|
|
86
86
|
registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
|
|
87
87
|
});
|
|
88
88
|
|
|
@@ -111,7 +111,7 @@ describe("/api/restart works without piGateway (no-op broadcast)", () => {
|
|
|
111
111
|
|
|
112
112
|
beforeEach(() => {
|
|
113
113
|
fastify = Fastify();
|
|
114
|
-
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
|
|
114
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
|
|
115
115
|
registerSystemRoutes(fastify, makeNoopDeps()); // no piGateway
|
|
116
116
|
});
|
|
117
117
|
|
|
@@ -145,6 +145,7 @@ export function buildConfig(flags: Partial<ServerConfig>): ServerConfig {
|
|
|
145
145
|
maxWsBufferBytes: fileConfig.memoryLimits.maxWsBufferBytes,
|
|
146
146
|
editor: fileConfig.editor,
|
|
147
147
|
openspec: fileConfig.openspec,
|
|
148
|
+
reattachPlacement: fileConfig.reattachPlacement,
|
|
148
149
|
resolvedTrustedNetworks: fileConfig.resolvedTrustedNetworks,
|
|
149
150
|
corsAllowedOrigins: fileConfig.cors.allowedOrigins,
|
|
150
151
|
};
|
|
@@ -582,6 +582,15 @@ export function wireEvents(deps: EventWiringDeps): void {
|
|
|
582
582
|
browserGateway.broadcastSessionUpdated(sessionId, gitUpdates);
|
|
583
583
|
}
|
|
584
584
|
|
|
585
|
+
if (msg.type === "jj_state_update") {
|
|
586
|
+
// jjState is intentionally allowed to be `undefined` (no jj) when
|
|
587
|
+
// the bridge sends `null`; the session-manager update applies the
|
|
588
|
+
// value verbatim. See change: add-jj-workspace-plugin.
|
|
589
|
+
const jjUpdates = { jjState: msg.jjState ?? undefined };
|
|
590
|
+
sessionManager.update(sessionId, jjUpdates);
|
|
591
|
+
browserGateway.broadcastSessionUpdated(sessionId, jjUpdates);
|
|
592
|
+
}
|
|
593
|
+
|
|
585
594
|
if (msg.type === "files_list") {
|
|
586
595
|
browserGateway.sendToSubscribers(sessionId, {
|
|
587
596
|
type: "files_list",
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parser + writer for an OpenSpec change's `tasks.md` file.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* ## 1. Group heading
|
|
6
|
-
* - [ ] 1.1 Task text
|
|
7
|
-
* - [x] 1.2 Done task
|
|
4
|
+
* Accepted shapes (top-level only — leading whitespace is rejected):
|
|
5
|
+
* ## 1. Group heading (group context)
|
|
6
|
+
* - [ ] 1.1 Task text (id-ed: numeric `1.1`-style id)
|
|
7
|
+
* - [x] 1.2 Done task (id-ed, ticked)
|
|
8
|
+
* - [ ] Verify runner image (id-less: parser synthesizes `L<line>`)
|
|
9
|
+
* - [x] Add matrix row (id-less, ticked)
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
* (indented sublists, free-form prose, etc.).
|
|
11
|
+
* Indented sublists and free-form prose are ignored.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* The synthesized `L<line>` id (e.g. `L17` for the 7th line of the file) is a
|
|
14
|
+
* stable opaque token — it round-trips through the toggle endpoint as the
|
|
15
|
+
* `id` param but is NEVER written to disk. The `line` field is the actual
|
|
16
|
+
* byte-level optimistic-concurrency token; the id is just a cross-check.
|
|
17
|
+
*
|
|
18
|
+
* Writes rewrite exactly one line's checkbox marker character and preserve
|
|
19
|
+
* everything else byte-for-byte (including the original spacing between `]`
|
|
20
|
+
* and the id/text); atomic via write-then-rename.
|
|
14
21
|
*/
|
|
15
22
|
import fs from "node:fs/promises";
|
|
16
23
|
import path from "node:path";
|
|
@@ -46,11 +53,21 @@ export class NotACheckboxError extends Error {
|
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
// Top-level checkbox
|
|
50
|
-
//
|
|
51
|
-
|
|
56
|
+
// Top-level checkbox with positional groups so the writer can rebuild the line
|
|
57
|
+
// byte-for-byte. Groups (1-indexed):
|
|
58
|
+
// 1: "- [" (literal prefix)
|
|
59
|
+
// 2: " " | "x" | "X" (the marker char — the only thing the writer flips)
|
|
60
|
+
// 3: "] " plus any extra spaces (literal separator, preserved verbatim)
|
|
61
|
+
// 4: "1.1 " (numeric id + its trailing whitespace) OR "" (id-less)
|
|
62
|
+
// 5: the remainder of the line (the task text)
|
|
63
|
+
const CHECKBOX_RE = /^(- \[)([ xX])(\] +)((?:[0-9]+(?:\.[0-9]+)* +)?)(.*)$/;
|
|
52
64
|
const HEADING_RE = /^##\s+(.*)$/;
|
|
53
65
|
|
|
66
|
+
/** Synthesize the canonical id for an id-less line: `L<1-indexed-line>`. */
|
|
67
|
+
function synthesizeId(line1Indexed: number): string {
|
|
68
|
+
return `L${line1Indexed}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
export function parseTasksMarkdown(content: string): OpenSpecTask[] {
|
|
55
72
|
// Split on \n only; trailing \r is trimmed so we handle CRLF inputs too.
|
|
56
73
|
const lines = content.split("\n");
|
|
@@ -66,12 +83,15 @@ export function parseTasksMarkdown(content: string): OpenSpecTask[] {
|
|
|
66
83
|
}
|
|
67
84
|
const m = CHECKBOX_RE.exec(line);
|
|
68
85
|
if (!m) continue;
|
|
69
|
-
const done = m[
|
|
86
|
+
const done = m[2] === "x" || m[2] === "X";
|
|
87
|
+
const lineNo = i + 1;
|
|
88
|
+
// m[4] is "" when no numeric id present, "1.1 " otherwise.
|
|
89
|
+
const id = m[4] ? m[4].trimEnd() : synthesizeId(lineNo);
|
|
70
90
|
out.push({
|
|
71
|
-
id
|
|
72
|
-
text: m[
|
|
91
|
+
id,
|
|
92
|
+
text: m[5].trim(),
|
|
73
93
|
done,
|
|
74
|
-
line:
|
|
94
|
+
line: lineNo,
|
|
75
95
|
group: currentGroup,
|
|
76
96
|
});
|
|
77
97
|
}
|
|
@@ -121,16 +141,27 @@ export async function toggleTask(
|
|
|
121
141
|
|
|
122
142
|
const m = CHECKBOX_RE.exec(bare);
|
|
123
143
|
if (!m) throw new NotACheckboxError();
|
|
124
|
-
if (m[2] !== id) throw new LineMismatchError();
|
|
125
144
|
|
|
126
|
-
|
|
145
|
+
// Resolve the parsed id from the source line (numeric if present, else
|
|
146
|
+
// synthesized `L<line>`). The caller's `id` MUST match this exactly — a
|
|
147
|
+
// mismatch (numeric-vs-synthetic, wrong synthetic line number, or genuinely
|
|
148
|
+
// wrong id) is a line-mismatch.
|
|
149
|
+
const parsedId = m[4] ? m[4].trimEnd() : synthesizeId(line);
|
|
150
|
+
if (parsedId !== id) throw new LineMismatchError();
|
|
151
|
+
|
|
152
|
+
const currentDone = m[2] === "x" || m[2] === "X";
|
|
127
153
|
// Optimistic concurrency: the caller's `done` is the *target* state; the line
|
|
128
154
|
// must currently hold the opposite state. If it already matches, we treat
|
|
129
155
|
// that as a line-mismatch — the file changed under us.
|
|
130
156
|
if (currentDone === done) throw new LineMismatchError();
|
|
131
157
|
|
|
132
158
|
const marker = done ? "x" : " ";
|
|
133
|
-
|
|
159
|
+
// Byte-for-byte rewrite: swap ONLY the marker char in group 2; preserve
|
|
160
|
+
// group 1 (prefix), group 3 (separator + any extra spaces), group 4 (id +
|
|
161
|
+
// trailing space, possibly empty for id-less lines), and group 5 (text).
|
|
162
|
+
// This guarantees id-less lines do not acquire a synthetic id in the file,
|
|
163
|
+
// and id-ed lines retain their exact spacing.
|
|
164
|
+
const rewritten = m[1] + marker + m[3] + m[4] + m[5];
|
|
134
165
|
lines[idx] = hadCR ? rewritten + "\r" : rewritten;
|
|
135
166
|
|
|
136
167
|
const newContent = lines.join("\n");
|
|
@@ -140,7 +171,7 @@ export async function toggleTask(
|
|
|
140
171
|
|
|
141
172
|
return {
|
|
142
173
|
id,
|
|
143
|
-
text: m[
|
|
174
|
+
text: m[5].trim(),
|
|
144
175
|
done,
|
|
145
176
|
line,
|
|
146
177
|
group: findGroupForLine(lines, idx),
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jujutsu (jj) REST API routes (localhost-only).
|
|
3
|
+
*
|
|
4
|
+
* Endpoints:
|
|
5
|
+
* POST /api/jj/workspace/add — create workspace + spawn session
|
|
6
|
+
* POST /api/jj/workspace/forget — refuses on unfolded work; force escape
|
|
7
|
+
* POST /api/jj/init-colocated — refuses on dirty git index
|
|
8
|
+
* GET /api/jj/workspace/list — enumerate workspaces under cwd
|
|
9
|
+
*
|
|
10
|
+
* All endpoints are network-guarded. Workspace add reuses the same
|
|
11
|
+
* pending-attach + spawnPiSession lever as the OpenSpec attach-and-spawn
|
|
12
|
+
* flow. See changes: add-jj-workspace-plugin, add-folder-task-checker-and-spawn-attach.
|
|
13
|
+
*/
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import type { FastifyInstance } from "fastify";
|
|
18
|
+
import * as jj from "@blackbelt-technology/pi-dashboard-shared/platform/jj.js";
|
|
19
|
+
import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
|
|
20
|
+
import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
21
|
+
import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
22
|
+
import type { BrowserGateway } from "../browser-gateway.js";
|
|
23
|
+
import type { PendingAttachRegistry } from "../pending-attach-registry.js";
|
|
24
|
+
import { spawnPiSession } from "../process-manager.js";
|
|
25
|
+
import type { NetworkGuard } from "./route-deps.js";
|
|
26
|
+
import { safeRealpathSync } from "../resolve-path.js";
|
|
27
|
+
|
|
28
|
+
/** Workspace name regex per spec (filesystem + bookmark safety). */
|
|
29
|
+
const NAME_RE = /^[a-z0-9-]+$/;
|
|
30
|
+
|
|
31
|
+
export interface JjRoutesDeps {
|
|
32
|
+
browserGateway: BrowserGateway;
|
|
33
|
+
pendingAttachRegistry: PendingAttachRegistry;
|
|
34
|
+
networkGuard: NetworkGuard;
|
|
35
|
+
/** Optional plugin config accessor (defaults to current dashboard config). */
|
|
36
|
+
getWorkspaceRoot?: () => string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the workspace-root setting for a given repo. Currently global
|
|
41
|
+
* via the plugin config; per-repo override is explicitly out of scope
|
|
42
|
+
* (Decision 14). Falls back to `.shadow` when config is absent.
|
|
43
|
+
*/
|
|
44
|
+
function resolveWorkspaceRoot(deps: JjRoutesDeps): string {
|
|
45
|
+
if (deps.getWorkspaceRoot) return deps.getWorkspaceRoot();
|
|
46
|
+
// The plugin config is read from the dashboard config blob's `plugins.jj`
|
|
47
|
+
// namespace. Until the runtime config-validator wires that path here, we
|
|
48
|
+
// fall back to the documented default.
|
|
49
|
+
try {
|
|
50
|
+
const cfg = loadConfig() as unknown as { plugins?: { jj?: { workspaceRoot?: string } } };
|
|
51
|
+
return cfg.plugins?.jj?.workspaceRoot ?? ".shadow";
|
|
52
|
+
} catch {
|
|
53
|
+
return ".shadow";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pure preflight checks for `init-colocated`. Returns `null` on OK,
|
|
59
|
+
* else a `{ code, message }` object the caller can shape into 4xx.
|
|
60
|
+
*/
|
|
61
|
+
export function checkInitColocatedPreconditions(cwd: string):
|
|
62
|
+
| null
|
|
63
|
+
| { code: "INVALID_CWD" | "ALREADY_JJ" | "DIRTY_INDEX" | "NOT_GIT_REPO"; message: string } {
|
|
64
|
+
if (!cwd) return { code: "INVALID_CWD", message: "cwd is required" };
|
|
65
|
+
if (!existsSync(cwd)) return { code: "INVALID_CWD", message: `cwd does not exist: ${cwd}` };
|
|
66
|
+
if (existsSync(path.join(cwd, ".jj"))) {
|
|
67
|
+
return { code: "ALREADY_JJ", message: "cwd is already a jj repo" };
|
|
68
|
+
}
|
|
69
|
+
if (!existsSync(path.join(cwd, ".git"))) {
|
|
70
|
+
return { code: "NOT_GIT_REPO", message: "cwd is not a git repo" };
|
|
71
|
+
}
|
|
72
|
+
// git diff --cached --quiet exits 1 when index is dirty. Recipe-based
|
|
73
|
+
// helper for clarity and consistency with the rest of the codebase.
|
|
74
|
+
const indexResult = git.statusPorcelain({ cwd });
|
|
75
|
+
if (indexResult.ok) {
|
|
76
|
+
// Lines beginning with M, A, D, R, C, U in column 1 indicate INDEX
|
|
77
|
+
// changes (column 2 is the working tree). We refuse on any column-1
|
|
78
|
+
// mutation.
|
|
79
|
+
const dirty = indexResult.value
|
|
80
|
+
.split("\n")
|
|
81
|
+
.filter((l) => l.length >= 2 && /[MADRCU]/.test(l[0]!));
|
|
82
|
+
if (dirty.length > 0) {
|
|
83
|
+
return {
|
|
84
|
+
code: "DIRTY_INDEX",
|
|
85
|
+
message:
|
|
86
|
+
`git index has staged changes (${dirty.length} entr${dirty.length === 1 ? "y" : "ies"}); ` +
|
|
87
|
+
`commit or 'git reset' first. See spec scenario "Init refused on dirty index".`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function registerJjRoutes(fastify: FastifyInstance, deps: JjRoutesDeps) {
|
|
95
|
+
const { browserGateway, pendingAttachRegistry, networkGuard } = deps;
|
|
96
|
+
|
|
97
|
+
// ── GET /api/jj/workspace/list?cwd=… ────────────────────────────────────
|
|
98
|
+
fastify.get<{ Querystring: { cwd?: string } }>(
|
|
99
|
+
"/api/jj/workspace/list",
|
|
100
|
+
{ preHandler: networkGuard },
|
|
101
|
+
async (request, reply) => {
|
|
102
|
+
const cwd = request.query.cwd;
|
|
103
|
+
if (!cwd) {
|
|
104
|
+
reply.code(400);
|
|
105
|
+
return { success: false, error: "cwd is required" } satisfies ApiResponse;
|
|
106
|
+
}
|
|
107
|
+
if (!existsSync(path.join(cwd, ".jj"))) {
|
|
108
|
+
return { success: true, data: { workspaces: [] } } satisfies ApiResponse;
|
|
109
|
+
}
|
|
110
|
+
const result = jj.workspaceList({ cwd });
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
reply.code(500);
|
|
113
|
+
return {
|
|
114
|
+
success: false,
|
|
115
|
+
error: `jj workspace list failed: ${describeError(result.error)}`,
|
|
116
|
+
} satisfies ApiResponse;
|
|
117
|
+
}
|
|
118
|
+
const workspaces = jj.parseWorkspaceList(result.value);
|
|
119
|
+
return { success: true, data: { workspaces } } satisfies ApiResponse;
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// ── POST /api/jj/workspace/add ──────────────────────────────────────────
|
|
124
|
+
fastify.post<{
|
|
125
|
+
Body: { fromCwd?: string; name?: string; baseRev?: string; taskDescription?: string };
|
|
126
|
+
}>(
|
|
127
|
+
"/api/jj/workspace/add",
|
|
128
|
+
{ preHandler: networkGuard },
|
|
129
|
+
async (request, reply) => {
|
|
130
|
+
const { fromCwd, name, baseRev, taskDescription } = request.body ?? {};
|
|
131
|
+
|
|
132
|
+
if (!fromCwd) {
|
|
133
|
+
reply.code(400);
|
|
134
|
+
return { success: false, error: "fromCwd is required" } satisfies ApiResponse;
|
|
135
|
+
}
|
|
136
|
+
if (!name || !NAME_RE.test(name)) {
|
|
137
|
+
reply.code(400);
|
|
138
|
+
return {
|
|
139
|
+
success: false,
|
|
140
|
+
error: "INVALID_NAME: name must match /^[a-z0-9-]+$/",
|
|
141
|
+
} satisfies ApiResponse;
|
|
142
|
+
}
|
|
143
|
+
if (!existsSync(path.join(fromCwd, ".jj"))) {
|
|
144
|
+
reply.code(400);
|
|
145
|
+
return {
|
|
146
|
+
success: false,
|
|
147
|
+
error: "fromCwd is not a jj repo",
|
|
148
|
+
} satisfies ApiResponse;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const workspaceRoot = resolveWorkspaceRoot(deps);
|
|
152
|
+
const destPath = path.join(fromCwd, workspaceRoot, name);
|
|
153
|
+
if (existsSync(destPath)) {
|
|
154
|
+
reply.code(409);
|
|
155
|
+
return {
|
|
156
|
+
success: false,
|
|
157
|
+
error: `destination already exists: ${destPath}`,
|
|
158
|
+
} satisfies ApiResponse;
|
|
159
|
+
}
|
|
160
|
+
// Ensure the workspace-root parent directory exists. `jj workspace
|
|
161
|
+
// add` does NOT create intermediate dirs and fails with
|
|
162
|
+
// "Cannot access <path>" on a missing parent. mkdir -p is safe and
|
|
163
|
+
// idempotent. The .shadow root should be in .gitignore (the spec's
|
|
164
|
+
// FolderOpenSpecSection-style hint is tracked as follow-up).
|
|
165
|
+
const parentDir = path.dirname(destPath);
|
|
166
|
+
try {
|
|
167
|
+
await fs.mkdir(parentDir, { recursive: true });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
reply.code(500);
|
|
170
|
+
return {
|
|
171
|
+
success: false,
|
|
172
|
+
error: `failed to create workspace parent dir ${parentDir}: ${err instanceof Error ? err.message : String(err)}`,
|
|
173
|
+
} satisfies ApiResponse;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Resolve the base revision when omitted: current bookmark of fromCwd's
|
|
177
|
+
// working copy, falling back to `trunk()` revset.
|
|
178
|
+
let resolvedBase = baseRev;
|
|
179
|
+
if (!resolvedBase) {
|
|
180
|
+
const bookmarksResult = jj.logRevset({
|
|
181
|
+
cwd: fromCwd,
|
|
182
|
+
revset: "@",
|
|
183
|
+
template: 'bookmarks ++ "\\n"',
|
|
184
|
+
});
|
|
185
|
+
if (bookmarksResult.ok) {
|
|
186
|
+
const first = bookmarksResult.value.trim().split("\n")[0]?.trim();
|
|
187
|
+
if (first) resolvedBase = first;
|
|
188
|
+
}
|
|
189
|
+
if (!resolvedBase) resolvedBase = "trunk()";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const addResult = jj.workspaceAdd({
|
|
193
|
+
cwd: fromCwd,
|
|
194
|
+
destPath,
|
|
195
|
+
baseRev: resolvedBase,
|
|
196
|
+
});
|
|
197
|
+
if (!addResult.ok) {
|
|
198
|
+
reply.code(500);
|
|
199
|
+
return {
|
|
200
|
+
success: false,
|
|
201
|
+
error: `jj workspace add failed: ${describeError(addResult.error)}`,
|
|
202
|
+
} satisfies ApiResponse;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const realDestPath = safeRealpathSync(destPath);
|
|
206
|
+
pendingAttachRegistry.enqueue(realDestPath, name);
|
|
207
|
+
|
|
208
|
+
// Spawn a session in the new workspace. Mirrors the OpenSpec
|
|
209
|
+
// attach-and-spawn flow; the bridge's `session_register` will
|
|
210
|
+
// consume the pending-attach intent and apply the auto-rename.
|
|
211
|
+
try {
|
|
212
|
+
const config = loadConfig();
|
|
213
|
+
const spawnResult = await spawnPiSession(realDestPath, {
|
|
214
|
+
strategy: config.spawnStrategy,
|
|
215
|
+
});
|
|
216
|
+
if (spawnResult.process && spawnResult.pid) {
|
|
217
|
+
browserGateway.headlessPidRegistry.register(
|
|
218
|
+
spawnResult.pid,
|
|
219
|
+
realDestPath,
|
|
220
|
+
spawnResult.process,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (!spawnResult.success) {
|
|
224
|
+
reply.code(202);
|
|
225
|
+
return {
|
|
226
|
+
success: true,
|
|
227
|
+
data: {
|
|
228
|
+
workspacePath: realDestPath,
|
|
229
|
+
spawned: false,
|
|
230
|
+
spawnMessage: spawnResult.message,
|
|
231
|
+
},
|
|
232
|
+
} satisfies ApiResponse;
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
success: true,
|
|
236
|
+
data: {
|
|
237
|
+
workspacePath: realDestPath,
|
|
238
|
+
spawned: true,
|
|
239
|
+
taskDescription: taskDescription ?? null,
|
|
240
|
+
},
|
|
241
|
+
} satisfies ApiResponse;
|
|
242
|
+
} catch (err) {
|
|
243
|
+
reply.code(202);
|
|
244
|
+
return {
|
|
245
|
+
success: true,
|
|
246
|
+
data: {
|
|
247
|
+
workspacePath: realDestPath,
|
|
248
|
+
spawned: false,
|
|
249
|
+
spawnMessage: err instanceof Error ? err.message : String(err),
|
|
250
|
+
},
|
|
251
|
+
} satisfies ApiResponse;
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// ── POST /api/jj/workspace/forget ───────────────────────────────────────
|
|
257
|
+
fastify.post<{
|
|
258
|
+
Body: { cwd?: string; name?: string; force?: boolean };
|
|
259
|
+
}>(
|
|
260
|
+
"/api/jj/workspace/forget",
|
|
261
|
+
{ preHandler: networkGuard },
|
|
262
|
+
async (request, reply) => {
|
|
263
|
+
const { cwd, name, force } = request.body ?? {};
|
|
264
|
+
|
|
265
|
+
if (!cwd) {
|
|
266
|
+
reply.code(400);
|
|
267
|
+
return { success: false, error: "cwd is required" } satisfies ApiResponse;
|
|
268
|
+
}
|
|
269
|
+
if (!name || !NAME_RE.test(name)) {
|
|
270
|
+
reply.code(400);
|
|
271
|
+
return {
|
|
272
|
+
success: false,
|
|
273
|
+
error: "INVALID_NAME: name must match /^[a-z0-9-]+$/",
|
|
274
|
+
} satisfies ApiResponse;
|
|
275
|
+
}
|
|
276
|
+
if (!existsSync(path.join(cwd, ".jj"))) {
|
|
277
|
+
reply.code(400);
|
|
278
|
+
return {
|
|
279
|
+
success: false,
|
|
280
|
+
error: "cwd is not a jj repo",
|
|
281
|
+
} satisfies ApiResponse;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Inspect for unfolded commits: anything in the workspace's `@`
|
|
285
|
+
// that isn't an ancestor of trunk. `trunk()..<name>@` is the
|
|
286
|
+
// straight-line revset for that; we filter out empty changes
|
|
287
|
+
// (`~empty()`) so the empty `@` of a freshly-created workspace
|
|
288
|
+
// doesn't trigger the unfolded-work refusal.
|
|
289
|
+
// Note: jj 0.40's `fork_point()` takes a single revset; we use
|
|
290
|
+
// the simpler `..` range form which works on every supported jj.
|
|
291
|
+
let unfolded: string[] = [];
|
|
292
|
+
const logResult = jj.logRevset({
|
|
293
|
+
cwd,
|
|
294
|
+
revset: `trunk()..${name}@ & ~empty()`,
|
|
295
|
+
template: 'change_id.short() ++ " " ++ description.first_line() ++ "\\n"',
|
|
296
|
+
});
|
|
297
|
+
if (logResult.ok) {
|
|
298
|
+
unfolded = logResult.value
|
|
299
|
+
.split("\n")
|
|
300
|
+
.map((l) => l.trim())
|
|
301
|
+
.filter(Boolean);
|
|
302
|
+
}
|
|
303
|
+
// A failed revset (e.g. unknown bookmark / fork_point unsupported) is
|
|
304
|
+
// *not* sufficient to skip the safety check — refuse with a generic
|
|
305
|
+
// error so the user sees the underlying jj message.
|
|
306
|
+
if (!logResult.ok) {
|
|
307
|
+
reply.code(500);
|
|
308
|
+
return {
|
|
309
|
+
success: false,
|
|
310
|
+
error: `jj log probe failed: ${describeError(logResult.error)}`,
|
|
311
|
+
} satisfies ApiResponse;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (unfolded.length > 0 && !force) {
|
|
315
|
+
reply.code(409);
|
|
316
|
+
return {
|
|
317
|
+
success: false,
|
|
318
|
+
error: "UNFOLDED_WORK",
|
|
319
|
+
data: { unfolded },
|
|
320
|
+
} as unknown as ApiResponse;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Forget + remove directory.
|
|
324
|
+
const forgetResult = jj.workspaceForget({ cwd, name });
|
|
325
|
+
if (!forgetResult.ok) {
|
|
326
|
+
reply.code(500);
|
|
327
|
+
return {
|
|
328
|
+
success: false,
|
|
329
|
+
error: `jj workspace forget failed: ${describeError(forgetResult.error)}`,
|
|
330
|
+
} satisfies ApiResponse;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const workspaceRoot = resolveWorkspaceRoot(deps);
|
|
334
|
+
const dirPath = path.join(cwd, workspaceRoot, name);
|
|
335
|
+
try {
|
|
336
|
+
await fs.rm(dirPath, { recursive: true, force: true });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
// Forget already succeeded; surface the rm error but don't fail
|
|
339
|
+
// the operation overall — the workspace is gone from jj's view.
|
|
340
|
+
request.log.warn(
|
|
341
|
+
`jj workspace dir cleanup failed (${dirPath}): ${err instanceof Error ? err.message : String(err)}`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { success: true, data: { name, force: Boolean(force) } } satisfies ApiResponse;
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// ── POST /api/jj/init-colocated ─────────────────────────────────────────
|
|
350
|
+
fastify.post<{ Body: { cwd?: string } }>(
|
|
351
|
+
"/api/jj/init-colocated",
|
|
352
|
+
{ preHandler: networkGuard },
|
|
353
|
+
async (request, reply) => {
|
|
354
|
+
const { cwd } = request.body ?? {};
|
|
355
|
+
const precheck = checkInitColocatedPreconditions(cwd ?? "");
|
|
356
|
+
if (precheck) {
|
|
357
|
+
reply.code(precheck.code === "DIRTY_INDEX" ? 409 : 400);
|
|
358
|
+
return {
|
|
359
|
+
success: false,
|
|
360
|
+
error: precheck.code,
|
|
361
|
+
data: { message: precheck.message },
|
|
362
|
+
} as unknown as ApiResponse;
|
|
363
|
+
}
|
|
364
|
+
const result = jj.gitInitColocate({ cwd: cwd! });
|
|
365
|
+
if (!result.ok) {
|
|
366
|
+
reply.code(500);
|
|
367
|
+
return {
|
|
368
|
+
success: false,
|
|
369
|
+
error: `jj git init --colocate failed: ${describeError(result.error)}`,
|
|
370
|
+
} satisfies ApiResponse;
|
|
371
|
+
}
|
|
372
|
+
return { success: true, data: { cwd } } satisfies ApiResponse;
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function describeError(error: { kind: string; [k: string]: unknown }): string {
|
|
378
|
+
if (error.kind === "not-found") return `binary not found: ${String(error.binary ?? "jj")}`;
|
|
379
|
+
if (error.kind === "timeout") return `timed out after ${String(error.timeoutMs)}ms`;
|
|
380
|
+
if (error.kind === "exit") {
|
|
381
|
+
const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
|
|
382
|
+
return stderr.split("\n")[0] || `exited ${String(error.code)}`;
|
|
383
|
+
}
|
|
384
|
+
if (error.kind === "spawn-failure") return String(error.message ?? "spawn failed");
|
|
385
|
+
return error.kind;
|
|
386
|
+
}
|
|
@@ -8,7 +8,7 @@ import type { SessionManager } from "../memory-session-manager.js";
|
|
|
8
8
|
import type { EventStore } from "../memory-event-store.js";
|
|
9
9
|
import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
10
10
|
import type { NetworkGuard } from "./route-deps.js";
|
|
11
|
-
import { extractFileChanges,
|
|
11
|
+
import { extractFileChanges, enrichWithVcsDiff } from "../session-diff.js";
|
|
12
12
|
|
|
13
13
|
export function registerSessionRoutes(
|
|
14
14
|
fastify: FastifyInstance,
|
|
@@ -52,8 +52,17 @@ export function registerSessionRoutes(
|
|
|
52
52
|
}
|
|
53
53
|
const events = eventStore.getEvents(sessionId, 0).map((e) => e.event);
|
|
54
54
|
const files = extractFileChanges(events, session.cwd);
|
|
55
|
-
const
|
|
56
|
-
return {
|
|
55
|
+
const result = enrichWithVcsDiff(session.cwd, files, session.jjState);
|
|
56
|
+
return {
|
|
57
|
+
success: true,
|
|
58
|
+
data: {
|
|
59
|
+
files: result.enrichedFiles,
|
|
60
|
+
isGitRepo: result.isGitRepo,
|
|
61
|
+
vcsKind: result.vcsKind,
|
|
62
|
+
diffBase: result.diffBase,
|
|
63
|
+
baseLabel: result.baseLabel,
|
|
64
|
+
},
|
|
65
|
+
} satisfies ApiResponse;
|
|
57
66
|
},
|
|
58
67
|
);
|
|
59
68
|
|