@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.5.0
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 +342 -267
- package/README.md +51 -2
- package/docs/architecture.md +266 -25
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- 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 +142 -4
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/model-tracker.ts +35 -1
- package/packages/extension/src/prompt-bus.ts +4 -3
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +6 -1
- package/packages/extension/src/vcs-info.ts +184 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- 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-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +22 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +57 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/openspec-tasks.ts +50 -19
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/jj-routes.ts +386 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/session-routes.ts +12 -3
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +16 -9
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-diff.ts +118 -1
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +29 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/diff-types.ts +17 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/jj.ts +405 -0
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +60 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +19 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +91 -0
- package/packages/extension/src/git-info.ts +0 -55
|
@@ -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.
|
|
3
|
+
"version": "0.5.0",
|
|
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.
|
|
35
|
-
"@blackbelt-technology/pi-dashboard-extension": "^0.
|
|
36
|
-
"@blackbelt-technology/pi-dashboard-shared": "^0.
|
|
34
|
+
"@blackbelt-technology/dashboard-plugin-runtime": "^0.5.0",
|
|
35
|
+
"@blackbelt-technology/pi-dashboard-extension": "^0.5.0",
|
|
36
|
+
"@blackbelt-technology/pi-dashboard-shared": "^0.5.0",
|
|
37
37
|
"@fastify/compress": "^8.3.1",
|
|
38
38
|
"@fastify/cookie": "^11.0.2",
|
|
39
39
|
"@fastify/cors": "^11.0.0",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defense-in-depth at the auto-attach rename site (event-wiring.ts).
|
|
3
|
+
*
|
|
4
|
+
* The detector (`detectOpenSpecActivity`) already rejects non-slug-shaped
|
|
5
|
+
* change names after fix-uuid-rename-bug. This file tests the second layer:
|
|
6
|
+
* even if a future detector regression returns a junk `changeName`, the
|
|
7
|
+
* auto-attach branch in `event-wiring.ts` MUST refuse to mutate session state
|
|
8
|
+
* or send `rename_session`.
|
|
9
|
+
*
|
|
10
|
+
* Approach: mock `detectOpenSpecActivity` to return a UUID-shaped result,
|
|
11
|
+
* drive a tool_execution_start event end-to-end, assert no mutation.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
14
|
+
import { WebSocket } from "ws";
|
|
15
|
+
|
|
16
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js", async (importOriginal) => {
|
|
17
|
+
const actual = await importOriginal<typeof import("@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js")>();
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
detectOpenSpecActivity: vi.fn(() => ({
|
|
21
|
+
changeName: "019df0aa-1234-5678-9abc-def012345678",
|
|
22
|
+
isActive: true,
|
|
23
|
+
})),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Imported AFTER vi.mock so the server picks up the mocked module.
|
|
28
|
+
const { createServer } = await import("../server.js");
|
|
29
|
+
|
|
30
|
+
async function connectSession(piPort: number, sessionId: string): Promise<WebSocket> {
|
|
31
|
+
const ws = new WebSocket(`ws://localhost:${piPort}`);
|
|
32
|
+
await new Promise<void>((resolve) => {
|
|
33
|
+
ws.on("open", () => {
|
|
34
|
+
ws.send(JSON.stringify({
|
|
35
|
+
type: "session_register",
|
|
36
|
+
sessionId,
|
|
37
|
+
cwd: "/tmp",
|
|
38
|
+
source: "cli",
|
|
39
|
+
}));
|
|
40
|
+
ws.send(JSON.stringify({ type: "replay_complete", sessionId }));
|
|
41
|
+
setTimeout(resolve, 50);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
return ws;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("Auto-attach defense-in-depth: rename site rejects non-slug changeName", () => {
|
|
48
|
+
let server: Awaited<ReturnType<typeof createServer>>;
|
|
49
|
+
let piPort: number;
|
|
50
|
+
let browserPort: number;
|
|
51
|
+
let ws: WebSocket;
|
|
52
|
+
const piMessages: any[] = [];
|
|
53
|
+
|
|
54
|
+
let testPort = 19200;
|
|
55
|
+
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
testPort += 2;
|
|
58
|
+
browserPort = testPort;
|
|
59
|
+
piPort = testPort + 1;
|
|
60
|
+
piMessages.length = 0;
|
|
61
|
+
server = await createServer({
|
|
62
|
+
port: browserPort,
|
|
63
|
+
piPort,
|
|
64
|
+
dev: true,
|
|
65
|
+
autoShutdown: false,
|
|
66
|
+
shutdownIdleSeconds: 999,
|
|
67
|
+
tunnel: false,
|
|
68
|
+
editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
|
|
69
|
+
});
|
|
70
|
+
await server.start();
|
|
71
|
+
ws = await connectSession(piPort, "s1");
|
|
72
|
+
ws.on("message", (raw) => {
|
|
73
|
+
try { piMessages.push(JSON.parse(raw.toString())); } catch { /* ignore */ }
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(async () => {
|
|
78
|
+
ws.close();
|
|
79
|
+
await server.stop();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does NOT mutate openspecChange / attachedProposal / name when detector returns a UUID", async () => {
|
|
83
|
+
// Any tool_execution_start triggers the (mocked) detector. Path content is
|
|
84
|
+
// irrelevant — the mock ignores its inputs and returns a UUID changeName.
|
|
85
|
+
ws.send(JSON.stringify({
|
|
86
|
+
type: "event_forward",
|
|
87
|
+
sessionId: "s1",
|
|
88
|
+
event: {
|
|
89
|
+
eventType: "tool_execution_start",
|
|
90
|
+
timestamp: Date.now(),
|
|
91
|
+
data: { toolName: "Write", args: { path: "openspec/changes/add-auth/proposal.md" } },
|
|
92
|
+
},
|
|
93
|
+
}));
|
|
94
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
95
|
+
|
|
96
|
+
const session = server.sessionManager.get("s1");
|
|
97
|
+
expect(session?.openspecChange).toBeFalsy();
|
|
98
|
+
expect(session?.attachedProposal).toBeFalsy();
|
|
99
|
+
expect(session?.name).toBeFalsy();
|
|
100
|
+
|
|
101
|
+
const renameSent = piMessages.some((m) => m.type === "rename_session");
|
|
102
|
+
expect(renameSent).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for bootstrapInstallFromList.
|
|
3
|
+
*
|
|
4
|
+
* All file I/O and install calls are injected via opts so no real
|
|
5
|
+
* filesystem or subprocesses are touched.
|
|
6
|
+
*
|
|
7
|
+
* See change: simplify-electron-bootstrap-derived-state.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
10
|
+
import { createBootstrapState } from "../bootstrap-state.js";
|
|
11
|
+
import {
|
|
12
|
+
bootstrapInstallFromList,
|
|
13
|
+
type PackageInstaller,
|
|
14
|
+
} from "../bootstrap-install-from-list.js";
|
|
15
|
+
import type { InstallablePackage, InstallableList } from "@blackbelt-technology/pi-dashboard-shared/installable-list.js";
|
|
16
|
+
|
|
17
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function makePackage(overrides: Partial<InstallablePackage> = {}): InstallablePackage {
|
|
20
|
+
return {
|
|
21
|
+
name: "test-pkg",
|
|
22
|
+
version: "1.0.0",
|
|
23
|
+
required: true,
|
|
24
|
+
kind: "npm",
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeList(packages: InstallablePackage[]): InstallableList {
|
|
30
|
+
return { version: "1", packages };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a `bootstrapInstallFromList` opts object that bypasses all real I/O.
|
|
35
|
+
* - `listResult`: the installable list (null = file absent).
|
|
36
|
+
* - `installedNames`: package names that are already installed.
|
|
37
|
+
* - `npmInstall`/`piInstall`: injectable install fns (default: succeed).
|
|
38
|
+
*/
|
|
39
|
+
interface FakeOpts {
|
|
40
|
+
listResult: InstallableList | null;
|
|
41
|
+
installedNames?: string[];
|
|
42
|
+
npmInstall?: PackageInstaller;
|
|
43
|
+
piInstall?: PackageInstaller;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildOpts(fake: FakeOpts, extra?: object) {
|
|
47
|
+
const installedSet = new Set(fake.installedNames ?? []);
|
|
48
|
+
return {
|
|
49
|
+
configDir: "/fake/config",
|
|
50
|
+
managedDir: "/fake/managed",
|
|
51
|
+
isInstalled: (pkg: InstallablePackage) => installedSet.has(pkg.name),
|
|
52
|
+
npmInstall: fake.npmInstall ?? (async () => { /* succeed */ }),
|
|
53
|
+
piInstall: fake.piInstall ?? (async () => { /* succeed */ }),
|
|
54
|
+
// Override readInstallableList via module mock — done per test via vi.mock
|
|
55
|
+
// (see below). We instead inject listResult via a wrapping helper.
|
|
56
|
+
...extra,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// We cannot easily mock `readInstallableList` without vi.mock at module level.
|
|
61
|
+
// Instead we factor out a testable inner function and re-export it.
|
|
62
|
+
// Since we cannot easily mock the module import inside bootstrapInstallFromList,
|
|
63
|
+
// we use a different approach: inject a `_readList` seam via opts.
|
|
64
|
+
//
|
|
65
|
+
// However, the current public API doesn't expose that seam. We'll test via
|
|
66
|
+
// the observable side effects (bootstrap state + thrown errors) and fake the
|
|
67
|
+
// injectable installers. For the list itself, we monkey-patch the module.
|
|
68
|
+
//
|
|
69
|
+
// Pragmatic solution: use vi.mock to replace readInstallableList.
|
|
70
|
+
|
|
71
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/installable-list.js", () => ({
|
|
72
|
+
readInstallableList: vi.fn(),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
import { readInstallableList } from "@blackbelt-technology/pi-dashboard-shared/installable-list.js";
|
|
76
|
+
const mockReadList = vi.mocked(readInstallableList);
|
|
77
|
+
|
|
78
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe("bootstrapInstallFromList", () => {
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
vi.clearAllMocks();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── Test 1: no installable.json (Bridge/Standalone parity) ──────────────
|
|
86
|
+
|
|
87
|
+
describe("file-absent path", () => {
|
|
88
|
+
it("returns immediately without setting installable state or calling any installer", async () => {
|
|
89
|
+
mockReadList.mockResolvedValue(null);
|
|
90
|
+
const state = createBootstrapState();
|
|
91
|
+
const npmInstall = vi.fn();
|
|
92
|
+
const piInstall = vi.fn();
|
|
93
|
+
|
|
94
|
+
await bootstrapInstallFromList(state, {
|
|
95
|
+
configDir: "/fake/config",
|
|
96
|
+
managedDir: "/fake/managed",
|
|
97
|
+
npmInstall,
|
|
98
|
+
piInstall,
|
|
99
|
+
isInstalled: () => false,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// No installer calls.
|
|
103
|
+
expect(npmInstall).not.toHaveBeenCalled();
|
|
104
|
+
expect(piInstall).not.toHaveBeenCalled();
|
|
105
|
+
|
|
106
|
+
// installable field NOT set (file was absent — no tracking started).
|
|
107
|
+
expect(state.get().installable).toBeUndefined();
|
|
108
|
+
|
|
109
|
+
// Status remains ready.
|
|
110
|
+
expect(state.get().status).toBe("ready");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── Test 2: synthetic installable.json ──────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe("with installable.json present", () => {
|
|
117
|
+
it("skips already-installed npm package, installs missing required + optional, final state is correct", async () => {
|
|
118
|
+
const alreadyInstalled = makePackage({ name: "already-installed-pkg", required: false });
|
|
119
|
+
const missingRequired = makePackage({ name: "missing-required-pkg", required: true });
|
|
120
|
+
const missingOptional = makePackage({ name: "missing-optional-pkg", required: false });
|
|
121
|
+
|
|
122
|
+
mockReadList.mockResolvedValue(
|
|
123
|
+
makeList([alreadyInstalled, missingRequired, missingOptional]),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const state = createBootstrapState();
|
|
127
|
+
const npmInstall = vi.fn().mockResolvedValue(undefined);
|
|
128
|
+
|
|
129
|
+
await bootstrapInstallFromList(state, {
|
|
130
|
+
configDir: "/fake/config",
|
|
131
|
+
managedDir: "/fake/managed",
|
|
132
|
+
npmInstall,
|
|
133
|
+
piInstall: vi.fn(),
|
|
134
|
+
isInstalled: (pkg) => pkg.name === "already-installed-pkg",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Two install calls (already-installed skipped).
|
|
138
|
+
expect(npmInstall).toHaveBeenCalledTimes(2);
|
|
139
|
+
expect(npmInstall.mock.calls[0][0].name).toBe("missing-required-pkg");
|
|
140
|
+
expect(npmInstall.mock.calls[1][0].name).toBe("missing-optional-pkg");
|
|
141
|
+
|
|
142
|
+
// Final state: installed=3 (1 pre-installed + 2 freshly installed), failed=0.
|
|
143
|
+
const installable = state.get().installable;
|
|
144
|
+
expect(installable).toBeDefined();
|
|
145
|
+
expect(installable!.total).toBe(3);
|
|
146
|
+
expect(installable!.installed).toBe(3);
|
|
147
|
+
expect(installable!.failed).toHaveLength(0);
|
|
148
|
+
|
|
149
|
+
// Status remains ready (no error).
|
|
150
|
+
expect(state.get().status).toBe("ready");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("installs pi-extension packages via piInstall", async () => {
|
|
154
|
+
const pkg = makePackage({ name: "my-extension", kind: "pi-extension", required: true });
|
|
155
|
+
mockReadList.mockResolvedValue(makeList([pkg]));
|
|
156
|
+
|
|
157
|
+
const state = createBootstrapState();
|
|
158
|
+
const piInstall = vi.fn().mockResolvedValue(undefined);
|
|
159
|
+
const npmInstall = vi.fn();
|
|
160
|
+
|
|
161
|
+
await bootstrapInstallFromList(state, {
|
|
162
|
+
configDir: "/fake/config",
|
|
163
|
+
managedDir: "/fake/managed",
|
|
164
|
+
npmInstall,
|
|
165
|
+
piInstall,
|
|
166
|
+
isInstalled: () => false,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(piInstall).toHaveBeenCalledOnce();
|
|
170
|
+
expect(npmInstall).not.toHaveBeenCalled();
|
|
171
|
+
expect(piInstall.mock.calls[0][0].name).toBe("my-extension");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("optional package failure is recorded in failed[] but does not throw", async () => {
|
|
175
|
+
const optionalFail = makePackage({ name: "optional-bad", required: false });
|
|
176
|
+
mockReadList.mockResolvedValue(makeList([optionalFail]));
|
|
177
|
+
|
|
178
|
+
const state = createBootstrapState();
|
|
179
|
+
const npmInstall = vi.fn().mockRejectedValue(new Error("network error"));
|
|
180
|
+
|
|
181
|
+
await expect(
|
|
182
|
+
bootstrapInstallFromList(state, {
|
|
183
|
+
configDir: "/fake/config",
|
|
184
|
+
managedDir: "/fake/managed",
|
|
185
|
+
npmInstall,
|
|
186
|
+
piInstall: vi.fn(),
|
|
187
|
+
isInstalled: () => false,
|
|
188
|
+
}),
|
|
189
|
+
).resolves.toBeUndefined();
|
|
190
|
+
|
|
191
|
+
const installable = state.get().installable;
|
|
192
|
+
expect(installable!.failed).toEqual(["optional-bad"]);
|
|
193
|
+
expect(installable!.installed).toBe(0);
|
|
194
|
+
expect(state.get().status).toBe("ready");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("required package failure sets status=failed and throws", async () => {
|
|
198
|
+
const requiredFail = makePackage({ name: "required-bad", required: true });
|
|
199
|
+
mockReadList.mockResolvedValue(makeList([requiredFail]));
|
|
200
|
+
|
|
201
|
+
const state = createBootstrapState();
|
|
202
|
+
const npmInstall = vi.fn().mockRejectedValue(new Error("disk full"));
|
|
203
|
+
|
|
204
|
+
await expect(
|
|
205
|
+
bootstrapInstallFromList(state, {
|
|
206
|
+
configDir: "/fake/config",
|
|
207
|
+
managedDir: "/fake/managed",
|
|
208
|
+
npmInstall,
|
|
209
|
+
piInstall: vi.fn(),
|
|
210
|
+
isInstalled: () => false,
|
|
211
|
+
}),
|
|
212
|
+
).rejects.toThrow('Required package "required-bad" failed to install');
|
|
213
|
+
|
|
214
|
+
expect(state.get().status).toBe("failed");
|
|
215
|
+
expect(state.get().error?.message).toContain("required-bad");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("deprecated and defaultOff packages are skipped entirely", async () => {
|
|
219
|
+
const deprecated = makePackage({ name: "old-pkg", deprecated: true });
|
|
220
|
+
const defaultOff = makePackage({ name: "opt-pkg", defaultOff: true });
|
|
221
|
+
const normal = makePackage({ name: "normal-pkg", required: true });
|
|
222
|
+
mockReadList.mockResolvedValue(makeList([deprecated, defaultOff, normal]));
|
|
223
|
+
|
|
224
|
+
const state = createBootstrapState();
|
|
225
|
+
const npmInstall = vi.fn().mockResolvedValue(undefined);
|
|
226
|
+
|
|
227
|
+
await bootstrapInstallFromList(state, {
|
|
228
|
+
configDir: "/fake/config",
|
|
229
|
+
managedDir: "/fake/managed",
|
|
230
|
+
npmInstall,
|
|
231
|
+
piInstall: vi.fn(),
|
|
232
|
+
isInstalled: () => false,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Only "normal-pkg" is processed (total=1, not 3).
|
|
236
|
+
expect(npmInstall).toHaveBeenCalledOnce();
|
|
237
|
+
expect(state.get().installable!.total).toBe(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("emits progress steps during install", async () => {
|
|
241
|
+
const pkg = makePackage({ name: "tracked-pkg" });
|
|
242
|
+
mockReadList.mockResolvedValue(makeList([pkg]));
|
|
243
|
+
|
|
244
|
+
const state = createBootstrapState();
|
|
245
|
+
const progressSteps: string[] = [];
|
|
246
|
+
state.subscribe((s) => {
|
|
247
|
+
if (s.progress) progressSteps.push(s.progress.step);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const npmInstall = vi.fn().mockResolvedValue(undefined);
|
|
251
|
+
|
|
252
|
+
await bootstrapInstallFromList(state, {
|
|
253
|
+
configDir: "/fake/config",
|
|
254
|
+
managedDir: "/fake/managed",
|
|
255
|
+
npmInstall,
|
|
256
|
+
piInstall: vi.fn(),
|
|
257
|
+
isInstalled: () => false,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(progressSteps).toContain("tracked-pkg");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|