@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,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
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveAuthJsonKey,
|
|
19
19
|
type ApiKeyCredential,
|
|
20
20
|
} from "../provider-auth-storage.js";
|
|
21
|
+
import { getLatestCatalogue } from "../provider-catalogue-cache.js";
|
|
21
22
|
import { startCallbackServer } from "../oauth-callback-server.js";
|
|
22
23
|
import type { PiGateway } from "../pi-gateway.js";
|
|
23
24
|
import type { BrowserGateway } from "../browser-gateway.js";
|
|
@@ -98,6 +99,14 @@ export function registerProviderAuthRoutes(
|
|
|
98
99
|
|
|
99
100
|
// Full status (OAuth + API key)
|
|
100
101
|
fastify.get("/api/provider-auth/status", async () => {
|
|
102
|
+
// Cold-cache nudge: if no bridge has pushed a catalogue yet, ask
|
|
103
|
+
// every connected pi to send one. Best-effort, doesn't block this
|
|
104
|
+
// response. See change: replace-hardcoded-provider-lists.
|
|
105
|
+
if (getLatestCatalogue().length === 0) {
|
|
106
|
+
for (const sid of piGateway.getConnectedSessionIds()) {
|
|
107
|
+
piGateway.sendToSession(sid, { type: "request_providers", sessionId: sid });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
101
110
|
return getAuthStatus();
|
|
102
111
|
});
|
|
103
112
|
|
|
@@ -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
|
|
|
@@ -19,8 +19,10 @@ import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.j
|
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import os from "node:os";
|
|
21
21
|
import { localhostGuard, netmaskToCidrBits, networkAddress } from "../localhost-guard.js";
|
|
22
|
+
import { readSpawnFailures } from "../spawn-failure-log.js";
|
|
22
23
|
import { getPluginStatusStore } from "@blackbelt-technology/dashboard-plugin-runtime/server";
|
|
23
24
|
import type { NetworkInterface } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
25
|
+
import type { BootstrapStateStore } from "../bootstrap-state.js";
|
|
24
26
|
|
|
25
27
|
export function registerSystemRoutes(
|
|
26
28
|
fastify: FastifyInstance,
|
|
@@ -33,9 +35,10 @@ export function registerSystemRoutes(
|
|
|
33
35
|
version?: string;
|
|
34
36
|
directoryService?: DirectoryService;
|
|
35
37
|
piGateway?: PiGateway;
|
|
38
|
+
bootstrapState?: BootstrapStateStore;
|
|
36
39
|
},
|
|
37
40
|
) {
|
|
38
|
-
const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService, piGateway } = deps;
|
|
41
|
+
const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService, piGateway, bootstrapState } = deps;
|
|
39
42
|
|
|
40
43
|
// Quiesce windows for the bridge `server_restarting` broadcast. See change
|
|
41
44
|
// `fix-restart-bridge-auto-start-race`. Bridges that receive this message
|
|
@@ -189,6 +192,8 @@ export function registerSystemRoutes(
|
|
|
189
192
|
return {
|
|
190
193
|
ok: true,
|
|
191
194
|
pid: process.pid,
|
|
195
|
+
starter: bootstrapState?.get().starter ?? "Standalone",
|
|
196
|
+
installable: bootstrapState?.get().installable,
|
|
192
197
|
version: version ?? "unknown",
|
|
193
198
|
uptime: Math.floor((Date.now() - serverStartTime) / 1000),
|
|
194
199
|
mode: config.dev ? "dev" : "production",
|
|
@@ -223,6 +228,26 @@ export function registerSystemRoutes(
|
|
|
223
228
|
},
|
|
224
229
|
);
|
|
225
230
|
|
|
231
|
+
// Re-extract endpoint — Electron-only; 403 for Bridge/Standalone, 202 for Electron.
|
|
232
|
+
// See change: simplify-electron-bootstrap-derived-state (task 6.4).
|
|
233
|
+
fastify.post(
|
|
234
|
+
"/api/electron/reextract",
|
|
235
|
+
{ preHandler: networkGuard },
|
|
236
|
+
async (_request, reply) => {
|
|
237
|
+
const starter = bootstrapState?.get().starter ?? "Standalone";
|
|
238
|
+
if (starter !== "Electron") {
|
|
239
|
+
reply.status(403);
|
|
240
|
+
return {
|
|
241
|
+
error: "reextract_not_allowed",
|
|
242
|
+
message: `Re-extract is only available when the server was started by Electron (current starter: ${starter})`,
|
|
243
|
+
starter,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
reply.status(202);
|
|
247
|
+
return { ok: true, message: "Re-extraction scheduled. Electron will restart the server." };
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
|
|
226
251
|
// Restart endpoint — flush state, spawn new server, then exit
|
|
227
252
|
fastify.post<{ Body: { dev?: boolean } }>(
|
|
228
253
|
"/api/restart",
|
|
@@ -269,6 +294,18 @@ export function registerSystemRoutes(
|
|
|
269
294
|
);
|
|
270
295
|
|
|
271
296
|
// Network interfaces for trusted networks UI (localhost-only for security)
|
|
297
|
+
// GET /api/spawn-failures — rolling log of failed spawn attempts. See change: spawn-failure-diagnostics.
|
|
298
|
+
fastify.get<{ Querystring: { limit?: string } }>(
|
|
299
|
+
"/api/spawn-failures",
|
|
300
|
+
async (request) => {
|
|
301
|
+
const rawLimit = request.query.limit;
|
|
302
|
+
const parsed = rawLimit !== undefined ? parseInt(rawLimit, 10) : NaN;
|
|
303
|
+
const limit = Number.isNaN(parsed) ? 50 : parsed;
|
|
304
|
+
const entries = readSpawnFailures(limit);
|
|
305
|
+
return { entries };
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
272
309
|
fastify.get(
|
|
273
310
|
"/api/network-interfaces",
|
|
274
311
|
{ preHandler: localhostGuard },
|
|
@@ -23,7 +23,7 @@ import { createPendingResumeIntentRegistry } from "./pending-resume-intent-regis
|
|
|
23
23
|
import { applyReattachPolicy } from "./reattach-placement.js";
|
|
24
24
|
|
|
25
25
|
// pending-load-manager removed — server loads sessions directly via DirectoryService
|
|
26
|
-
import { createDirectoryService, type DirectoryService } from "./directory-service.js";
|
|
26
|
+
import { createDirectoryService, isOpenSpecDataEmpty, type DirectoryService } from "./directory-service.js";
|
|
27
27
|
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
|
28
28
|
import { createTerminalGateway, type TerminalGateway } from "./terminal-gateway.js";
|
|
29
29
|
import { writePid, removePid } from "./server-pid.js";
|
|
@@ -45,6 +45,7 @@ import { registerGitRoutes } from "./routes/git-routes.js";
|
|
|
45
45
|
import { registerFileRoutes } from "./routes/file-routes.js";
|
|
46
46
|
import { registerOpenSpecRoutes } from "./routes/openspec-routes.js";
|
|
47
47
|
import { registerSystemRoutes } from "./routes/system-routes.js";
|
|
48
|
+
import { registerDoctorRoutes } from "./routes/doctor-routes.js";
|
|
48
49
|
import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js";
|
|
49
50
|
import { registerPackageRoutes } from "./routes/package-routes.js";
|
|
50
51
|
import { registerRecommendedRoutes, invalidateRecommendedCache } from "./routes/recommended-routes.js";
|
|
@@ -52,6 +53,7 @@ import { registerPiCoreRoutes } from "./routes/pi-core-routes.js";
|
|
|
52
53
|
import { PiCoreChecker } from "./pi-core-checker.js";
|
|
53
54
|
import { PiCoreUpdater } from "./pi-core-updater.js";
|
|
54
55
|
import { registerToolRoutes } from "./routes/tool-routes.js";
|
|
56
|
+
import { registerJjRoutes } from "./routes/jj-routes.js";
|
|
55
57
|
import { registerBootstrapRoutes } from "./routes/bootstrap-routes.js";
|
|
56
58
|
import { createBootstrapState, type BootstrapStateStore } from "./bootstrap-state.js";
|
|
57
59
|
import { createBootstrapQueue } from "./bootstrap-queue.js";
|
|
@@ -90,6 +92,10 @@ export interface ServerConfig {
|
|
|
90
92
|
editor: import("@blackbelt-technology/pi-dashboard-shared/config.js").EditorConfig;
|
|
91
93
|
/** OpenSpec polling config (interval, concurrency, change detection, jitter) */
|
|
92
94
|
openspec?: import("@blackbelt-technology/pi-dashboard-shared/config.js").OpenSpecPollConfig;
|
|
95
|
+
/** Reattach-placement policy applied when a bridge re-registers after
|
|
96
|
+
* a dashboard restart. Defaults to `"always"`.
|
|
97
|
+
* See change: reattach-move-to-front. */
|
|
98
|
+
reattachPlacement?: import("@blackbelt-technology/pi-dashboard-shared/config.js").ReattachPlacement;
|
|
93
99
|
/** Merged trusted networks from config */
|
|
94
100
|
resolvedTrustedNetworks?: string[];
|
|
95
101
|
/** CORS allowed origins from config */
|
|
@@ -138,10 +144,6 @@ export interface PostInstallRepairDeps {
|
|
|
138
144
|
browserGateway: { broadcastToAll(msg: ServerToBrowserMessage): void };
|
|
139
145
|
}
|
|
140
146
|
|
|
141
|
-
function isOpenSpecDataEmpty(d: OpenSpecData | undefined): boolean {
|
|
142
|
-
if (!d) return true;
|
|
143
|
-
return !d.initialized && (!d.changes || d.changes.length === 0);
|
|
144
|
-
}
|
|
145
147
|
|
|
146
148
|
/**
|
|
147
149
|
* Centralized post-install repair work fired on every `installing → ready`
|
|
@@ -366,7 +368,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
366
368
|
applyReattachPolicy(
|
|
367
369
|
sessionId,
|
|
368
370
|
session.cwd,
|
|
369
|
-
config.reattachPlacement,
|
|
371
|
+
config.reattachPlacement ?? "always",
|
|
370
372
|
{ sessionManager, sessionOrderManager, browserGateway },
|
|
371
373
|
ctx.priorStatus,
|
|
372
374
|
);
|
|
@@ -416,7 +418,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
416
418
|
applyReattachPolicy(
|
|
417
419
|
sessionId,
|
|
418
420
|
session.cwd,
|
|
419
|
-
config.reattachPlacement,
|
|
421
|
+
config.reattachPlacement ?? "always",
|
|
420
422
|
{ sessionManager, sessionOrderManager, browserGateway },
|
|
421
423
|
ctx.priorStatus,
|
|
422
424
|
);
|
|
@@ -558,7 +560,9 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
558
560
|
});
|
|
559
561
|
|
|
560
562
|
// Auto-shutdown idle timer
|
|
561
|
-
|
|
563
|
+
// Active terminals keep the server alive even when no pi sessions are
|
|
564
|
+
// attached. See change: fix-terminal-half-height-dual-mount.
|
|
565
|
+
const idleTimer = createIdleTimer(config, piGateway, () => terminalManager.list().length > 0);
|
|
562
566
|
|
|
563
567
|
const fastify = Fastify({
|
|
564
568
|
logger: false,
|
|
@@ -704,8 +708,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
704
708
|
if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
|
|
705
709
|
},
|
|
706
710
|
});
|
|
707
|
-
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway });
|
|
711
|
+
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway, bootstrapState });
|
|
712
|
+
// GET /api/doctor — see change: doctor-rich-output (task 4.2). Auth-gated identically to /api/config.
|
|
713
|
+
registerDoctorRoutes(fastify);
|
|
708
714
|
registerToolRoutes(fastify, { registry: getDefaultRegistry(), networkGuard });
|
|
715
|
+
registerJjRoutes(fastify, { browserGateway, pendingAttachRegistry, networkGuard });
|
|
709
716
|
|
|
710
717
|
// ── Bootstrap REST routes ────────────────────────────────────────
|
|
711
718
|
// The routes module is registered here; state + queue are declared
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { SessionManager } from "./memory-session-manager.js";
|
|
6
6
|
import type { BrowserGateway } from "./browser-gateway.js";
|
|
7
|
-
import type
|
|
7
|
+
import { isOpenSpecDataEmpty, type DirectoryService } from "./directory-service.js";
|
|
8
8
|
import { extractSessionStats } from "./session-stats-reader.js";
|
|
9
9
|
|
|
10
10
|
export interface SessionBootstrapDeps {
|
|
@@ -75,20 +75,35 @@ export async function discoverAndBroadcastSessions(deps: SessionBootstrapDeps):
|
|
|
75
75
|
|
|
76
76
|
// Initial OpenSpec poll for all known directories.
|
|
77
77
|
//
|
|
78
|
-
//
|
|
79
|
-
// (spawnSync per change) — on Windows with many active changes
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// openspec data populates in the background and pushes `openspec_update`
|
|
84
|
-
// broadcasts to browsers as each directory finishes.
|
|
78
|
+
// Fire-and-forget: `refreshOpenSpec` / `pollOpenSpec` is synchronous internally
|
|
79
|
+
// (spawnSync per change) — on Windows with many active changes and multiple
|
|
80
|
+
// pinned directories this can block the event loop for minutes, making the
|
|
81
|
+
// HTTP server unresponsive during startup. We intentionally do NOT await it
|
|
82
|
+
// here so HTTP + WebSocket startup completes immediately.
|
|
85
83
|
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
84
|
+
// After each directory's poll completes, broadcast `openspec_update` to all
|
|
85
|
+
// connected browsers if the prior cache was empty/undefined or the polled
|
|
86
|
+
// data differs from prior — mirroring the proven `runPostInstallRepair`
|
|
87
|
+
// pattern in `server.ts`. This is what unblocks cold-boot Electron clients
|
|
88
|
+
// that connected before the cache was hot.
|
|
89
|
+
//
|
|
90
|
+
// A proper fix for the slow `spawnSync` path is to migrate the openspec
|
|
91
|
+
// Recipe to async spawn; tracked separately. See change:
|
|
92
|
+
// consolidate-tool-resolution. This change covers the broadcast wiring only.
|
|
93
|
+
// See change: fix-cold-boot-openspec-protocol.
|
|
88
94
|
void Promise.all(
|
|
89
95
|
directoryService.knownDirectories().map(async (cwd) => {
|
|
90
|
-
try {
|
|
91
|
-
|
|
96
|
+
try {
|
|
97
|
+
const prior = directoryService.getOpenSpecData(cwd);
|
|
98
|
+
const fresh = await directoryService.refreshOpenSpec(cwd);
|
|
99
|
+
const priorEmpty = isOpenSpecDataEmpty(prior);
|
|
100
|
+
const dataDiffers = JSON.stringify(prior) !== JSON.stringify(fresh);
|
|
101
|
+
if (priorEmpty || dataDiffers) {
|
|
102
|
+
browserGateway.broadcastToAll({ type: "openspec_update", cwd, data: fresh });
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error(`[dashboard] initial openspec poll failed for ${cwd}:`, err);
|
|
106
|
+
}
|
|
92
107
|
}),
|
|
93
108
|
);
|
|
94
109
|
}
|