@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
|
@@ -34,7 +34,7 @@ import { scanChildProcesses } from "./process-scanner.js";
|
|
|
34
34
|
import type { BridgeContext } from "./bridge-context.js";
|
|
35
35
|
import { filterHiddenCommands, extractFirstMessage, getCurrentModelString } from "./bridge-context.js";
|
|
36
36
|
import { sendStateSync as _sendStateSync, replaySessionEntries as _replaySessionEntries, handleSessionChange as _handleSessionChange } from "./session-sync.js";
|
|
37
|
-
import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged } from "./model-tracker.js";
|
|
37
|
+
import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged, sendJjStateIfChanged as _sendJjStateIfChanged, resetReconnectCaches as _resetReconnectCaches } from "./model-tracker.js";
|
|
38
38
|
import { registerFlowEventListeners, FLOW_EVENT_MAP, SUBAGENT_EVENT_MAP } from "./flow-event-wiring.js";
|
|
39
39
|
import { refreshUiModules, subscribeUiInvalidate, handleUiManagement, type UiModulesBridgeCtx } from "./ui-modules.js";
|
|
40
40
|
|
|
@@ -180,6 +180,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
180
180
|
const trackedPgids = new Set<number>(); // PGIDs captured during bash tool calls
|
|
181
181
|
let lastGitBranch: string | undefined;
|
|
182
182
|
let lastGitPrNumber: number | undefined;
|
|
183
|
+
let lastJjStateJson: string | undefined; // see change: add-jj-workspace-plugin
|
|
183
184
|
let lastSessionName: string | undefined;
|
|
184
185
|
let cachedHasUI: boolean | undefined = prev.hasUI;
|
|
185
186
|
let cachedModelRegistry: any | undefined = prev.modelRegistry;
|
|
@@ -469,7 +470,23 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
469
470
|
}),
|
|
470
471
|
onReconnect: safe(() => {
|
|
471
472
|
if (!isActive()) return; // Stale listener guard
|
|
473
|
+
// Reset caches that aren't persisted server-side so the upcoming
|
|
474
|
+
// 30s tick (and the inline calls below) re-emit the live state.
|
|
475
|
+
// See change: add-jj-workspace-plugin.
|
|
476
|
+
const _bc = syncBc();
|
|
477
|
+
_resetReconnectCaches(_bc);
|
|
478
|
+
applyBc(_bc);
|
|
472
479
|
sendStateSync();
|
|
480
|
+
// Force-emit jj/git state for the active session’s cwd. The bridge
|
|
481
|
+
// doesn't have direct ctx here, so we walk the active session.
|
|
482
|
+
try {
|
|
483
|
+
const activeId = (pi as any).getCurrentSessionId?.();
|
|
484
|
+
const activeCtx = activeId ? (pi as any).getCtx?.(activeId) : (cachedCtx as any);
|
|
485
|
+
if (activeCtx?.cwd) {
|
|
486
|
+
sendGitInfoIfChanged(activeCtx.cwd);
|
|
487
|
+
sendJjStateIfChanged(activeCtx.cwd);
|
|
488
|
+
}
|
|
489
|
+
} catch { /* probe failure non-fatal */ }
|
|
473
490
|
replaySessionEntries();
|
|
474
491
|
// Re-send pending PromptBus requests so dashboard dialogs survive browser refresh.
|
|
475
492
|
// Synchronous within this tick to prevent TUI respond() from interleaving.
|
|
@@ -611,6 +628,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
611
628
|
lastModel, lastThinkingLevel,
|
|
612
629
|
lastSessionFile, lastSessionDir, lastFirstMessage,
|
|
613
630
|
lastGitBranch, lastGitPrNumber, lastSessionName,
|
|
631
|
+
lastJjStateJson,
|
|
614
632
|
hasRegisteredOnce,
|
|
615
633
|
};
|
|
616
634
|
}
|
|
@@ -628,6 +646,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
628
646
|
lastGitBranch = bc.lastGitBranch;
|
|
629
647
|
lastGitPrNumber = bc.lastGitPrNumber;
|
|
630
648
|
lastSessionName = bc.lastSessionName;
|
|
649
|
+
lastJjStateJson = bc.lastJjStateJson;
|
|
631
650
|
hasRegisteredOnce = bc.hasRegisteredOnce;
|
|
632
651
|
}
|
|
633
652
|
|
|
@@ -637,6 +656,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
637
656
|
function sendModelUpdateIfChanged() { const bc = syncBc(); _sendModelUpdateIfChanged(bc); applyBc(bc); }
|
|
638
657
|
function sendSessionNameIfChanged() { const bc = syncBc(); _sendSessionNameIfChanged(bc); applyBc(bc); }
|
|
639
658
|
function sendGitInfoIfChanged(cwd: string) { const bc = syncBc(); _sendGitInfoIfChanged(bc, cwd); applyBc(bc); }
|
|
659
|
+
function sendJjStateIfChanged(cwd: string) { const bc = syncBc(); _sendJjStateIfChanged(bc, cwd); applyBc(bc); }
|
|
640
660
|
|
|
641
661
|
// Forward all pi core events to the dashboard.
|
|
642
662
|
// Events with special enrichment logic:
|
|
@@ -836,7 +856,13 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
836
856
|
// ── PromptBus setup ──
|
|
837
857
|
// Create bus with dashboard connection wiring.
|
|
838
858
|
// Replaces the old ui-proxy race pattern.
|
|
859
|
+
// Convert seconds → milliseconds for PromptBus.
|
|
860
|
+
// Values <= 0 (e.g. -1) are passed through as-is to signal infinite wait.
|
|
861
|
+
const askUserTimeoutMs = config.askUserPromptTimeoutSeconds > 0
|
|
862
|
+
? config.askUserPromptTimeoutSeconds * 1000
|
|
863
|
+
: -1;
|
|
839
864
|
promptBus = new PromptBus({
|
|
865
|
+
timeoutMs: askUserTimeoutMs,
|
|
840
866
|
onDashboardRequest: (prompt, component, placement) => {
|
|
841
867
|
connection.send({
|
|
842
868
|
type: "prompt_request" as any,
|
|
@@ -1225,8 +1251,9 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
1225
1251
|
}
|
|
1226
1252
|
}).catch(() => { stopSpinner(); });
|
|
1227
1253
|
|
|
1228
|
-
// Send initial git info
|
|
1254
|
+
// Send initial git + jj info
|
|
1229
1255
|
sendGitInfoIfChanged(ctx.cwd);
|
|
1256
|
+
sendJjStateIfChanged(ctx.cwd);
|
|
1230
1257
|
|
|
1231
1258
|
// Start metrics monitor and heartbeat
|
|
1232
1259
|
startMetricsMonitor();
|
|
@@ -1240,10 +1267,11 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
1240
1267
|
}, HEARTBEAT_INTERVAL);
|
|
1241
1268
|
getBridgeState().timers!.push(heartbeatTimer);
|
|
1242
1269
|
|
|
1243
|
-
// Start git
|
|
1270
|
+
// Start git + jj + name/model polling
|
|
1244
1271
|
gitPollTimer = setInterval(() => {
|
|
1245
1272
|
if (!isActive()) return;
|
|
1246
1273
|
sendGitInfoIfChanged(ctx.cwd);
|
|
1274
|
+
sendJjStateIfChanged(ctx.cwd);
|
|
1247
1275
|
sendSessionNameIfChanged();
|
|
1248
1276
|
sendModelUpdateIfChanged();
|
|
1249
1277
|
}, GIT_POLL_INTERVAL);
|
|
@@ -1287,6 +1315,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
1287
1315
|
if (gitPollTimer) clearInterval(gitPollTimer);
|
|
1288
1316
|
gitPollTimer = setInterval(() => {
|
|
1289
1317
|
sendGitInfoIfChanged(ctx.cwd);
|
|
1318
|
+
sendJjStateIfChanged(ctx.cwd);
|
|
1290
1319
|
}, GIT_POLL_INTERVAL);
|
|
1291
1320
|
}
|
|
1292
1321
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { BridgeContext } from "./bridge-context.js";
|
|
6
6
|
import { getCurrentModelString } from "./bridge-context.js";
|
|
7
|
-
import { gatherGitInfo } from "./
|
|
7
|
+
import { gatherGitInfo, gatherJjInfo } from "./vcs-info.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Send model_update if model or thinking level has changed since last send.
|
|
@@ -54,3 +54,37 @@ export function sendGitInfoIfChanged(bc: BridgeContext, cwd: string): void {
|
|
|
54
54
|
...info,
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reset the change-detection caches that aren't persisted on the server
|
|
60
|
+
* side, so a server-restart-driven reconnect re-sends them. `gitBranch`
|
|
61
|
+
* is already persisted to `.meta.json` so it's tolerable for a tick of
|
|
62
|
+
* staleness; `jjState` is intentionally NOT persisted (live tool state)
|
|
63
|
+
* and must be re-emitted on every reconnect.
|
|
64
|
+
* See change: add-jj-workspace-plugin.
|
|
65
|
+
*/
|
|
66
|
+
export function resetReconnectCaches(bc: BridgeContext): void {
|
|
67
|
+
bc.lastJjStateJson = undefined;
|
|
68
|
+
// Defensive: also reset git so a reconnect through a stale state cache
|
|
69
|
+
// doesn't surface stale branch info if .meta.json wasn't persisted yet.
|
|
70
|
+
bc.lastGitBranch = undefined;
|
|
71
|
+
bc.lastGitPrNumber = undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Send jj_state_update if the cwd's jj state has changed since last send.
|
|
76
|
+
* Sends `null` to clear when the session leaves a jj repo (cwd switch).
|
|
77
|
+
* No-op when there's nothing to clear and nothing to send.
|
|
78
|
+
* See change: add-jj-workspace-plugin.
|
|
79
|
+
*/
|
|
80
|
+
export function sendJjStateIfChanged(bc: BridgeContext, cwd: string): void {
|
|
81
|
+
const state = gatherJjInfo(cwd);
|
|
82
|
+
const nextJson = state ? JSON.stringify(state) : "";
|
|
83
|
+
if (nextJson === (bc.lastJjStateJson ?? "")) return;
|
|
84
|
+
bc.lastJjStateJson = nextJson;
|
|
85
|
+
bc.connection.send({
|
|
86
|
+
type: "jj_state_update",
|
|
87
|
+
sessionId: bc.sessionId,
|
|
88
|
+
jjState: state ?? null,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -112,9 +112,10 @@ export class PromptBus {
|
|
|
112
112
|
|
|
113
113
|
return new Promise<PromptResponse>((resolve) => {
|
|
114
114
|
const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
// timeoutMs <= 0 means infinite — never fire a cancellation timer.
|
|
116
|
+
const timer = timeoutMs > 0
|
|
117
|
+
? setTimeout(() => { this.cancel(id); }, timeoutMs)
|
|
118
|
+
: (null as unknown as ReturnType<typeof setTimeout>);
|
|
118
119
|
|
|
119
120
|
// Distribute to all adapters and collect claims
|
|
120
121
|
const claims: PendingPrompt["claims"] = [];
|
|
@@ -6,7 +6,7 @@ import type { BridgeContext } from "./bridge-context.js";
|
|
|
6
6
|
import { getCurrentModelString, extractFirstMessage, filterHiddenCommands } from "./bridge-context.js";
|
|
7
7
|
import { detectSessionSource } from "./source-detector.js";
|
|
8
8
|
import { replayEntriesAsEvents } from "@blackbelt-technology/pi-dashboard-shared/state-replay.js";
|
|
9
|
-
import { gatherGitInfo } from "./
|
|
9
|
+
import { gatherGitInfo, gatherJjInfo } from "./vcs-info.js";
|
|
10
10
|
import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VCS info gathering — detects git branch/remote/PR AND jj workspace state.
|
|
3
|
+
* Delegates to shared platform tool modules so there's no inline execSync
|
|
4
|
+
* and every call benefits from the runner's safety defaults (windowsHide,
|
|
5
|
+
* timeout, tolerated exit codes).
|
|
6
|
+
*
|
|
7
|
+
* jj probing is fast-path-gated: when `<cwd>/.jj/` doesn't exist, NO `jj`
|
|
8
|
+
* subprocess is spawned. Only sessions inside a jj repo pay the probe cost.
|
|
9
|
+
*
|
|
10
|
+
* See changes: platform-command-executor, add-jj-workspace-plugin.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
|
|
15
|
+
import * as jj from "@blackbelt-technology/pi-dashboard-shared/platform/jj.js";
|
|
16
|
+
import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
|
|
17
|
+
import type { JjState } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
18
|
+
import { buildGitLinks, type GitLinks } from "./git-link-builder.js";
|
|
19
|
+
|
|
20
|
+
export interface GitInfo {
|
|
21
|
+
gitBranch: string;
|
|
22
|
+
gitBranchUrl?: string;
|
|
23
|
+
gitPrNumber?: number;
|
|
24
|
+
gitPrUrl?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Detect the current git branch. Returns short SHA for detached HEAD. */
|
|
28
|
+
export function detectBranch(cwd: string): string | undefined {
|
|
29
|
+
const ref = git.currentBranchOr({ cwd });
|
|
30
|
+
if (!ref) return undefined;
|
|
31
|
+
if (ref === "HEAD") {
|
|
32
|
+
// Detached HEAD — return short commit SHA
|
|
33
|
+
return git.headShaOr({ cwd, short: true }) ?? "HEAD";
|
|
34
|
+
}
|
|
35
|
+
return ref;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Detect the remote origin URL. */
|
|
39
|
+
export function detectRemoteUrl(cwd: string): string | undefined {
|
|
40
|
+
return git.remoteUrlOr({ cwd });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Detect the PR number via gh CLI (best effort). */
|
|
44
|
+
export function detectPrNumber(cwd: string): number | undefined {
|
|
45
|
+
return git.prNumberOr({ cwd });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Gather all git info for a directory. Returns undefined if not a git repo. */
|
|
49
|
+
export function gatherGitInfo(cwd: string): GitInfo | undefined {
|
|
50
|
+
const branch = detectBranch(cwd);
|
|
51
|
+
if (!branch) return undefined;
|
|
52
|
+
|
|
53
|
+
const remoteUrl = detectRemoteUrl(cwd);
|
|
54
|
+
const prNumber = detectPrNumber(cwd);
|
|
55
|
+
|
|
56
|
+
const links: GitLinks = remoteUrl ? buildGitLinks(remoteUrl, branch, prNumber) : {};
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
gitBranch: branch,
|
|
60
|
+
gitBranchUrl: links.branchUrl,
|
|
61
|
+
gitPrNumber: prNumber,
|
|
62
|
+
gitPrUrl: links.prUrl,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Jujutsu probing ────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Module-level cache: result of resolving `jj` once per process. The tool
|
|
70
|
+
* registry already memoizes resolutions, but reading it on every probe tick
|
|
71
|
+
* adds noise to traces. Single read on first probe, sticky for the process.
|
|
72
|
+
*/
|
|
73
|
+
let jjAvailable: boolean | undefined;
|
|
74
|
+
|
|
75
|
+
function isJjResolvable(): boolean {
|
|
76
|
+
if (jjAvailable !== undefined) return jjAvailable;
|
|
77
|
+
try {
|
|
78
|
+
const reg = getDefaultRegistry();
|
|
79
|
+
const res = reg.resolve("jj");
|
|
80
|
+
jjAvailable = res.ok;
|
|
81
|
+
} catch {
|
|
82
|
+
jjAvailable = false;
|
|
83
|
+
}
|
|
84
|
+
return jjAvailable;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Test-only hook to reset the jj-availability cache.
|
|
89
|
+
* Production code MUST NOT call this.
|
|
90
|
+
*/
|
|
91
|
+
export function _resetJjAvailableForTests(): void {
|
|
92
|
+
jjAvailable = undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Gather jj workspace state for a directory.
|
|
97
|
+
* Returns `undefined` when:
|
|
98
|
+
* - `jj` is not resolvable via the tool registry, OR
|
|
99
|
+
* - `.jj/` does not exist in cwd (fast path, no subprocess spawn).
|
|
100
|
+
*
|
|
101
|
+
* Returns a populated `JjState` when both conditions are met. Errors during
|
|
102
|
+
* `jj` invocation surface in `lastError` rather than throwing; the rest of
|
|
103
|
+
* the fields fall back to undefined / empty.
|
|
104
|
+
*
|
|
105
|
+
* Per spec scenario "Non-jj cwd incurs no jj subprocess cost".
|
|
106
|
+
*/
|
|
107
|
+
export function gatherJjInfo(cwd: string): JjState | undefined {
|
|
108
|
+
if (!isJjResolvable()) return undefined;
|
|
109
|
+
if (!existsSync(path.join(cwd, ".jj"))) return undefined;
|
|
110
|
+
|
|
111
|
+
const isColocated = existsSync(path.join(cwd, ".git"));
|
|
112
|
+
|
|
113
|
+
// Resolve workspace name + root. Errors are caught and surfaced via
|
|
114
|
+
// lastError so callers always get *some* JjState rather than nothing.
|
|
115
|
+
let workspaceName: string | undefined;
|
|
116
|
+
let workspaceRoot: string | undefined;
|
|
117
|
+
let lastError: string | undefined;
|
|
118
|
+
|
|
119
|
+
const rootResult = jj.workspaceRoot({ cwd });
|
|
120
|
+
if (rootResult.ok) {
|
|
121
|
+
workspaceRoot = rootResult.value;
|
|
122
|
+
} else if (rootResult.error.kind !== "not-found") {
|
|
123
|
+
lastError = describeJjError(rootResult.error);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Workspace name: parse `jj workspace list` and match by working-copy
|
|
127
|
+
// path. The CLI does not include the path in list output, so we fall
|
|
128
|
+
// back to identifying the workspace via the `<name>@` revset matching
|
|
129
|
+
// the workspace root. For now: if there's only one workspace, use
|
|
130
|
+
// its name; otherwise default to "default" if found, else first entry.
|
|
131
|
+
// (Multi-workspace name disambiguation tracked as Phase 4 follow-up.)
|
|
132
|
+
const listResult = jj.workspaceList({ cwd });
|
|
133
|
+
if (listResult.ok) {
|
|
134
|
+
const entries = jj.parseWorkspaceList(listResult.value);
|
|
135
|
+
if (entries.length === 1) {
|
|
136
|
+
workspaceName = entries[0]?.name;
|
|
137
|
+
} else if (entries.length > 1) {
|
|
138
|
+
workspaceName = entries.find((e) => e.name === "default")?.name
|
|
139
|
+
?? entries[0]?.name;
|
|
140
|
+
}
|
|
141
|
+
} else if (listResult.error.kind !== "not-found" && !lastError) {
|
|
142
|
+
lastError = describeJjError(listResult.error);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
isJjRepo: true,
|
|
147
|
+
isColocated,
|
|
148
|
+
workspaceName,
|
|
149
|
+
workspaceRoot,
|
|
150
|
+
lastError,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function describeJjError(
|
|
155
|
+
error: { kind: string; [k: string]: unknown },
|
|
156
|
+
): string {
|
|
157
|
+
if (error.kind === "timeout") return "jj probe timed out";
|
|
158
|
+
if (error.kind === "exit") {
|
|
159
|
+
const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
|
|
160
|
+
return stderr.split("\n")[0] || `jj exited ${String(error.code)}`;
|
|
161
|
+
}
|
|
162
|
+
if (error.kind === "spawn-failure") {
|
|
163
|
+
return typeof error.message === "string" ? error.message : "spawn failed";
|
|
164
|
+
}
|
|
165
|
+
return error.kind;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Combined VCS gather ────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
export interface VcsInfo {
|
|
171
|
+
git?: GitInfo;
|
|
172
|
+
jj?: JjState;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Convenience helper that gathers both git and jj info in one call.
|
|
177
|
+
* Used by the bridge's per-session 30 s probe tick.
|
|
178
|
+
*/
|
|
179
|
+
export function gatherVcsInfo(cwd: string): VcsInfo {
|
|
180
|
+
return {
|
|
181
|
+
git: gatherGitInfo(cwd),
|
|
182
|
+
jj: gatherJjInfo(cwd),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-dashboard-server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Dashboard server for monitoring and interacting with pi agent sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"postinstall": "node scripts/fix-pty-permissions.cjs"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@blackbelt-technology/dashboard-plugin-runtime": "^0.4.
|
|
35
|
-
"@blackbelt-technology/pi-dashboard-extension": "^0.4.
|
|
36
|
-
"@blackbelt-technology/pi-dashboard-shared": "^0.4.
|
|
34
|
+
"@blackbelt-technology/dashboard-plugin-runtime": "^0.4.6",
|
|
35
|
+
"@blackbelt-technology/pi-dashboard-extension": "^0.4.6",
|
|
36
|
+
"@blackbelt-technology/pi-dashboard-shared": "^0.4.6",
|
|
37
37
|
"@fastify/compress": "^8.3.1",
|
|
38
38
|
"@fastify/cookie": "^11.0.2",
|
|
39
39
|
"@fastify/cors": "^11.0.0",
|
|
@@ -67,8 +67,10 @@ describe("isUnreadTrigger", () => {
|
|
|
67
67
|
isUnreadTrigger(
|
|
68
68
|
"agent_end",
|
|
69
69
|
{ status: "streaming", currentTool: null },
|
|
70
|
-
//
|
|
71
|
-
|
|
70
|
+
// The status union is currently "streaming" | "idle" | "active";
|
|
71
|
+
// "ended" is not in the union but is a valid runtime value
|
|
72
|
+
// upstream, so we cast for the simulation.
|
|
73
|
+
{ status: "ended" as unknown as "idle", currentTool: null },
|
|
72
74
|
),
|
|
73
75
|
).toBe(false);
|
|
74
76
|
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the jj REST routes module — focused on the pure helpers
|
|
3
|
+
* (`checkInitColocatedPreconditions`) and validation logic. Full route
|
|
4
|
+
* integration tests are deferred until the test harness wires up a live
|
|
5
|
+
* Fastify instance + browserGateway mock.
|
|
6
|
+
*
|
|
7
|
+
* See change: add-jj-workspace-plugin.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
|
|
14
|
+
const { statusPorcelain } = vi.hoisted(() => ({
|
|
15
|
+
statusPorcelain: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/git.js", async () => {
|
|
19
|
+
const real = await vi.importActual<
|
|
20
|
+
typeof import("@blackbelt-technology/pi-dashboard-shared/platform/git.js")
|
|
21
|
+
>("@blackbelt-technology/pi-dashboard-shared/platform/git.js");
|
|
22
|
+
return { ...real, statusPorcelain };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
import { checkInitColocatedPreconditions } from "../routes/jj-routes.js";
|
|
26
|
+
|
|
27
|
+
describe("checkInitColocatedPreconditions", () => {
|
|
28
|
+
let tmpDir: string;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "jj-routes-test-"));
|
|
32
|
+
statusPorcelain.mockReset();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns INVALID_CWD when cwd is empty", () => {
|
|
40
|
+
expect(checkInitColocatedPreconditions("")?.code).toBe("INVALID_CWD");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns INVALID_CWD when cwd does not exist", () => {
|
|
44
|
+
expect(checkInitColocatedPreconditions("/nonexistent/path/12345")?.code).toBe("INVALID_CWD");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns ALREADY_JJ when .jj/ exists", () => {
|
|
48
|
+
fs.mkdirSync(path.join(tmpDir, ".jj"));
|
|
49
|
+
expect(checkInitColocatedPreconditions(tmpDir)?.code).toBe("ALREADY_JJ");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns NOT_GIT_REPO when neither .jj/ nor .git/ exist", () => {
|
|
53
|
+
expect(checkInitColocatedPreconditions(tmpDir)?.code).toBe("NOT_GIT_REPO");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns DIRTY_INDEX when git status has staged entries", () => {
|
|
57
|
+
fs.mkdirSync(path.join(tmpDir, ".git"));
|
|
58
|
+
statusPorcelain.mockReturnValue({
|
|
59
|
+
ok: true,
|
|
60
|
+
value: "M src/foo.ts\nA src/bar.ts\n",
|
|
61
|
+
});
|
|
62
|
+
const result = checkInitColocatedPreconditions(tmpDir);
|
|
63
|
+
expect(result?.code).toBe("DIRTY_INDEX");
|
|
64
|
+
expect(result?.message).toContain("2 entries");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns null on clean .git/ tree (working-tree dirt is fine)", () => {
|
|
68
|
+
fs.mkdirSync(path.join(tmpDir, ".git"));
|
|
69
|
+
// Lines starting with " M" (space then M) are working-tree-only changes.
|
|
70
|
+
// Lines starting with "??" are untracked files. Both are SAFE per spec
|
|
71
|
+
// scenario "Init allowed on unstaged dirty working tree".
|
|
72
|
+
statusPorcelain.mockReturnValue({
|
|
73
|
+
ok: true,
|
|
74
|
+
value: " M src/working-tree-mod.ts\n?? src/new-untracked.ts\n",
|
|
75
|
+
});
|
|
76
|
+
expect(checkInitColocatedPreconditions(tmpDir)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns null on totally clean tree", () => {
|
|
80
|
+
fs.mkdirSync(path.join(tmpDir, ".git"));
|
|
81
|
+
statusPorcelain.mockReturnValue({ ok: true, value: "" });
|
|
82
|
+
expect(checkInitColocatedPreconditions(tmpDir)).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns null when statusPorcelain itself fails (defensive: don't refuse on probe error)", () => {
|
|
86
|
+
fs.mkdirSync(path.join(tmpDir, ".git"));
|
|
87
|
+
statusPorcelain.mockReturnValue({
|
|
88
|
+
ok: false,
|
|
89
|
+
error: { kind: "not-found", binary: "git" },
|
|
90
|
+
});
|
|
91
|
+
expect(checkInitColocatedPreconditions(tmpDir)).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -69,6 +69,53 @@ describe("parseTasksMarkdown", () => {
|
|
|
69
69
|
const tasks = parseTasksMarkdown("- [ ] 1.1 Loose task");
|
|
70
70
|
expect(tasks[0].group).toBe("");
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
// ─── relax-tasks-parser-id-optional ───────────────────────────────────────
|
|
74
|
+
// The parser MUST accept top-level checkboxes with or without a `1.1`-style
|
|
75
|
+
// numeric id prefix. Id-less lines get a synthesized `L<line>` id.
|
|
76
|
+
|
|
77
|
+
it("parses id-less checkboxes with synthesized L<line> ids", () => {
|
|
78
|
+
const md = [
|
|
79
|
+
"## 1. Workflow matrix", // line 1
|
|
80
|
+
"", // line 2
|
|
81
|
+
"- [ ] Verify runner image", // line 3
|
|
82
|
+
"- [x] Add matrix row", // line 4
|
|
83
|
+
].join("\n");
|
|
84
|
+
const tasks = parseTasksMarkdown(md);
|
|
85
|
+
expect(tasks).toEqual([
|
|
86
|
+
{ id: "L3", text: "Verify runner image", done: false, line: 3, group: "1. Workflow matrix" },
|
|
87
|
+
{ id: "L4", text: "Add matrix row", done: true, line: 4, group: "1. Workflow matrix" },
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("parses files mixing id-ed and id-less checkboxes", () => {
|
|
92
|
+
const md = [
|
|
93
|
+
"## 1. Mix", // 1
|
|
94
|
+
"", // 2
|
|
95
|
+
"- [ ] 1.1 Has id", // 3
|
|
96
|
+
"- [x] No id here", // 4
|
|
97
|
+
"- [ ] 1.3 Skipped 1.2 on purpose", // 5
|
|
98
|
+
].join("\n");
|
|
99
|
+
const tasks = parseTasksMarkdown(md);
|
|
100
|
+
expect(tasks).toEqual([
|
|
101
|
+
{ id: "1.1", text: "Has id", done: false, line: 3, group: "1. Mix" },
|
|
102
|
+
{ id: "L4", text: "No id here", done: true, line: 4, group: "1. Mix" },
|
|
103
|
+
{ id: "1.3", text: "Skipped 1.2 on purpose", done: false, line: 5, group: "1. Mix" },
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("still ignores indented checkboxes (id-less or id-ed)", () => {
|
|
108
|
+
const md = [
|
|
109
|
+
"## G",
|
|
110
|
+
" - [ ] indented id-less",
|
|
111
|
+
" - [ ] 1.1 indented id-ed",
|
|
112
|
+
"- [ ] top-level id-less",
|
|
113
|
+
].join("\n");
|
|
114
|
+
const tasks = parseTasksMarkdown(md);
|
|
115
|
+
expect(tasks).toHaveLength(1);
|
|
116
|
+
expect(tasks[0].id).toBe("L4");
|
|
117
|
+
expect(tasks[0].text).toBe("top-level id-less");
|
|
118
|
+
});
|
|
72
119
|
});
|
|
73
120
|
|
|
74
121
|
describe("readTasks + toggleTask (writer)", () => {
|
|
@@ -175,4 +222,71 @@ describe("readTasks + toggleTask (writer)", () => {
|
|
|
175
222
|
const after = fs.readFileSync(tasksFile, "utf-8");
|
|
176
223
|
expect(after).toBe(weirdMd.replace("- [ ] 1.1 Task one", "- [x] 1.1 Task one"));
|
|
177
224
|
});
|
|
225
|
+
|
|
226
|
+
// ─── relax-tasks-parser-id-optional ───────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
describe("id-less round-trip", () => {
|
|
229
|
+
const idlessMd = [
|
|
230
|
+
"## 1. Workflow matrix", // 1
|
|
231
|
+
"", // 2
|
|
232
|
+
"- [ ] Verify runner image", // 3
|
|
233
|
+
"- [x] Add matrix row", // 4
|
|
234
|
+
"", // 5
|
|
235
|
+
"## 2. Verify rename behavior", // 6
|
|
236
|
+
"- [ ] Inspect releases", // 7
|
|
237
|
+
].join("\n");
|
|
238
|
+
|
|
239
|
+
beforeEach(() => {
|
|
240
|
+
fs.writeFileSync(tasksFile, idlessMd, "utf-8");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("toggle ticks an id-less task addressed by L<line>, no synthetic id leaks into the file", async () => {
|
|
244
|
+
const result = await toggleTask(tmpDir, CWD_CHANGE[1], "L3", true, 3);
|
|
245
|
+
expect(result).toEqual({
|
|
246
|
+
id: "L3",
|
|
247
|
+
text: "Verify runner image",
|
|
248
|
+
done: true,
|
|
249
|
+
line: 3,
|
|
250
|
+
group: "1. Workflow matrix",
|
|
251
|
+
});
|
|
252
|
+
const after = fs.readFileSync(tasksFile, "utf-8");
|
|
253
|
+
// CRITICAL: line shape preserved — no "L3" appears in the file body
|
|
254
|
+
expect(after).toBe(idlessMd.replace("- [ ] Verify runner image", "- [x] Verify runner image"));
|
|
255
|
+
expect(after).not.toContain("L3");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("toggle unticks an id-less task addressed by L<line>", async () => {
|
|
259
|
+
const result = await toggleTask(tmpDir, CWD_CHANGE[1], "L4", false, 4);
|
|
260
|
+
expect(result.done).toBe(false);
|
|
261
|
+
const after = fs.readFileSync(tasksFile, "utf-8");
|
|
262
|
+
expect(after).toBe(idlessMd.replace("- [x] Add matrix row", "- [ ] Add matrix row"));
|
|
263
|
+
expect(after).not.toContain("L4");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("toggle of id-less line with wrong synthesized id throws LineMismatchError", async () => {
|
|
267
|
+
// Line 3 is id-less; passing L99 (or any other L<n>) must reject
|
|
268
|
+
await expect(toggleTask(tmpDir, CWD_CHANGE[1], "L99", true, 3)).rejects.toBeInstanceOf(
|
|
269
|
+
LineMismatchError,
|
|
270
|
+
);
|
|
271
|
+
expect(fs.readFileSync(tasksFile, "utf-8")).toBe(idlessMd);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("toggle of id-less line with a numeric-style id throws LineMismatchError", async () => {
|
|
275
|
+
// Line 3 has no numeric id; passing "1.1" must reject
|
|
276
|
+
await expect(toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 3)).rejects.toBeInstanceOf(
|
|
277
|
+
LineMismatchError,
|
|
278
|
+
);
|
|
279
|
+
expect(fs.readFileSync(tasksFile, "utf-8")).toBe(idlessMd);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("toggle of id-ed line addressed by L<n> throws LineMismatchError", async () => {
|
|
283
|
+
// Switch fixture to id-ed for this case
|
|
284
|
+
fs.writeFileSync(tasksFile, initialMd, "utf-8");
|
|
285
|
+
// Line 3 has id "1.1"; addressing it as "L3" must reject
|
|
286
|
+
await expect(toggleTask(tmpDir, CWD_CHANGE[1], "L3", true, 3)).rejects.toBeInstanceOf(
|
|
287
|
+
LineMismatchError,
|
|
288
|
+
);
|
|
289
|
+
expect(fs.readFileSync(tasksFile, "utf-8")).toBe(initialMd);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
178
292
|
});
|