@blackbelt-technology/pi-agent-dashboard 0.4.4 → 0.4.5
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 +38 -33
- package/README.md +1 -0
- package/docs/architecture.md +162 -4
- package/package.json +4 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/connection-suppress-auto-start.test.ts +97 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +81 -1
- package/packages/extension/src/bridge-context.ts +10 -0
- package/packages/extension/src/bridge.ts +22 -0
- package/packages/extension/src/connection.ts +29 -0
- package/packages/extension/src/server-auto-start.ts +16 -0
- package/packages/extension/src/session-sync.ts +14 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/cli-restart.test.ts +78 -0
- package/packages/server/src/__tests__/config-api.test.ts +9 -0
- package/packages/server/src/__tests__/is-activity-event.test.ts +78 -0
- package/packages/server/src/__tests__/is-unread-trigger.test.ts +164 -0
- package/packages/server/src/__tests__/last-activity-broadcast.test.ts +190 -0
- package/packages/server/src/__tests__/reattach-placement.test.ts +231 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +25 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +117 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +31 -0
- package/packages/server/src/__tests__/system-routes-restart.test.ts +128 -0
- package/packages/server/src/__tests__/unread-persistence.test.ts +55 -0
- package/packages/server/src/__tests__/unread-trigger-wiring.test.ts +210 -0
- package/packages/server/src/__tests__/viewed-session-tracker.test.ts +93 -0
- package/packages/server/src/browser-gateway.ts +36 -0
- package/packages/server/src/cli.ts +70 -2
- package/packages/server/src/event-status-extraction.ts +98 -1
- package/packages/server/src/event-wiring.ts +70 -1
- package/packages/server/src/memory-session-manager.ts +34 -3
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/reattach-placement.ts +98 -0
- package/packages/server/src/restart-helper.ts +41 -2
- package/packages/server/src/routes/system-routes.ts +25 -1
- package/packages/server/src/server.ts +55 -3
- package/packages/server/src/session-scanner.ts +19 -0
- package/packages/server/src/viewed-session-tracker.ts +78 -0
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/config.test.ts +59 -0
- package/packages/shared/src/__tests__/mdns-discovery.test.ts +48 -1
- package/packages/shared/src/__tests__/no-bash-on-windows.test.ts +304 -0
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +1 -1
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +107 -0
- package/packages/shared/src/__tests__/protocol.test.ts +11 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +92 -0
- package/packages/shared/src/browser-protocol.ts +25 -0
- package/packages/shared/src/config.ts +41 -0
- package/packages/shared/src/mdns-discovery.ts +32 -1
- package/packages/shared/src/platform/node-spawn.ts +30 -0
- package/packages/shared/src/protocol.ts +30 -1
- package/packages/shared/src/session-meta.ts +6 -0
- package/packages/shared/src/types.ts +19 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-level invariant: no GitHub Actions step that can run on a
|
|
3
|
+
* Windows runner SHALL declare `shell: bash`. Bash on Windows runners
|
|
4
|
+
* is provided by Git for Windows' MSYS2 layer, which translates Win32
|
|
5
|
+
* paths to POSIX form (`D:\a\...` → `/d/a/...`) for any bash variable
|
|
6
|
+
* produced by `pwd`, `dirname`, etc. That POSIX-form string is invisible
|
|
7
|
+
* to native binaries (notably `node.exe`) when embedded in arguments,
|
|
8
|
+
* producing a recurring class of `MODULE_NOT_FOUND` / `ENOENT` bugs.
|
|
9
|
+
*
|
|
10
|
+
* Cross-OS build orchestration MUST be expressed in `.mjs` scripts
|
|
11
|
+
* invoked by `node`. POSIX-only steps MAY use `shell: bash` provided
|
|
12
|
+
* they are gated by an `if:` filter that excludes Windows. Windows-only
|
|
13
|
+
* steps MAY use `shell: pwsh`.
|
|
14
|
+
*
|
|
15
|
+
* If this test fails, port the offending step to `node` (cross-OS) or
|
|
16
|
+
* split it per-OS (`bash` for POSIX, `pwsh` for Windows) and gate each
|
|
17
|
+
* arm with an `if:` filter on `matrix.platform`.
|
|
18
|
+
*
|
|
19
|
+
* See change: eliminate-bash-on-windows-runners.
|
|
20
|
+
*
|
|
21
|
+
* Supported `if:` grammar (anything else fails closed → treated as
|
|
22
|
+
* Windows-reachable, forcing the contributor to write a recognised form
|
|
23
|
+
* or extend this evaluator):
|
|
24
|
+
*
|
|
25
|
+
* - bare boolean : `true` / `false`
|
|
26
|
+
* - matrix comparison : `matrix.platform == 'X'`, `matrix.platform != 'X'`
|
|
27
|
+
* `matrix.arch == 'X'`, `matrix.arch != 'X'`
|
|
28
|
+
* - conjunction : `<expr> && <expr>`
|
|
29
|
+
* - disjunction : `<expr> || <expr>`
|
|
30
|
+
* - negation : `!(<expr>)`
|
|
31
|
+
* - parens : `(<expr>)` are stripped
|
|
32
|
+
*
|
|
33
|
+
* The grammar is small because this repo's workflow YAML is small and
|
|
34
|
+
* stable; if a future workflow needs richer `if:` expressions, extend
|
|
35
|
+
* the evaluator (and the test grammar comment) rather than expand the
|
|
36
|
+
* lint's allowlist.
|
|
37
|
+
*/
|
|
38
|
+
import { describe, it, expect } from "vitest";
|
|
39
|
+
import fs from "node:fs";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
import url from "node:url";
|
|
42
|
+
|
|
43
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
44
|
+
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..", "..");
|
|
45
|
+
|
|
46
|
+
/** Workflow files this lint scans. */
|
|
47
|
+
const WORKFLOW_FILES: readonly string[] = [
|
|
48
|
+
".github/workflows/publish.yml",
|
|
49
|
+
".github/workflows/ci.yml",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** A platform value present in `matrix.platform` of any job we care about. */
|
|
53
|
+
const WINDOWS_PLATFORMS = new Set(["win32"]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract the YAML body of a top-level job by name. Returns the lines
|
|
57
|
+
* between ` <jobName>:` and the next sibling-indent (` `) job, or EOF.
|
|
58
|
+
*
|
|
59
|
+
* Same pattern as `publish-workflow-contract.test.ts` —
|
|
60
|
+
* regex-based extraction, no YAML library dep, tolerates the stable
|
|
61
|
+
* 2-space indented format used in this repo.
|
|
62
|
+
*/
|
|
63
|
+
function extractJobBlock(yaml: string, jobName: string): string | null {
|
|
64
|
+
const lines = yaml.split("\n");
|
|
65
|
+
const headerRe = new RegExp(`^ ${jobName}:\\s*$`);
|
|
66
|
+
let start = -1;
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
if (headerRe.test(lines[i])) {
|
|
69
|
+
start = i;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (start === -1) return null;
|
|
74
|
+
const siblingRe = /^ [a-z][a-z0-9-]*:\s*$/;
|
|
75
|
+
let end = lines.length;
|
|
76
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
77
|
+
if (siblingRe.test(lines[i])) {
|
|
78
|
+
end = i;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return lines.slice(start, end).join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Pull the matrix.include[*].platform values from a job block. */
|
|
86
|
+
function extractMatrixPlatforms(jobBlock: string): string[] {
|
|
87
|
+
// Each matrix entry starts with `- os:` at a deeper indent. Capture
|
|
88
|
+
// the `platform:` line that sibling-belongs to it (the next platform
|
|
89
|
+
// line after each `- os:`).
|
|
90
|
+
const out: string[] = [];
|
|
91
|
+
const lines = jobBlock.split("\n");
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
if (!/^\s+-\s*os:/.test(lines[i])) continue;
|
|
94
|
+
// Look at the next ~6 lines for a sibling `platform:` key.
|
|
95
|
+
for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) {
|
|
96
|
+
if (/^\s+-\s*os:/.test(lines[j])) break; // next entry
|
|
97
|
+
const m = lines[j].match(/^\s+platform:\s*['"]?([\w-]+)['"]?\s*$/);
|
|
98
|
+
if (m) {
|
|
99
|
+
out.push(m[1]);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Step-level extraction. Each step starts with ` - name:` (6-space
|
|
109
|
+
* indent under ` steps:`). For each step we capture the line number,
|
|
110
|
+
* the `name:` value, the `shell:` value (if any), and the `if:` value
|
|
111
|
+
* (if any).
|
|
112
|
+
*/
|
|
113
|
+
interface Step {
|
|
114
|
+
line: number; // 1-indexed
|
|
115
|
+
name: string;
|
|
116
|
+
shell: string | null;
|
|
117
|
+
if_: string | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function extractSteps(jobBlock: string, baseLine: number): Step[] {
|
|
121
|
+
const lines = jobBlock.split("\n");
|
|
122
|
+
const steps: Step[] = [];
|
|
123
|
+
let cur: Step | null = null;
|
|
124
|
+
for (let i = 0; i < lines.length; i++) {
|
|
125
|
+
const line = lines[i];
|
|
126
|
+
const stepHeader = line.match(/^ -\s*name:\s*(.+?)\s*$/);
|
|
127
|
+
if (stepHeader) {
|
|
128
|
+
if (cur) steps.push(cur);
|
|
129
|
+
cur = {
|
|
130
|
+
line: baseLine + i,
|
|
131
|
+
name: stepHeader[1].replace(/^['"]|['"]$/g, ""),
|
|
132
|
+
shell: null,
|
|
133
|
+
if_: null,
|
|
134
|
+
};
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (cur) {
|
|
138
|
+
// 8-space indented sibling keys of the step.
|
|
139
|
+
const sh = line.match(/^ shell:\s*(\S+)\s*$/);
|
|
140
|
+
if (sh) {
|
|
141
|
+
cur.shell = sh[1];
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const ifM = line.match(/^ if:\s*(.+?)\s*$/);
|
|
145
|
+
if (ifM) {
|
|
146
|
+
// Strip a single layer of YAML-double-quote wrapping, e.g.
|
|
147
|
+
// if: "!(matrix.platform == 'win32' && matrix.arch == 'arm64')"
|
|
148
|
+
// Single quotes inside the expression must be preserved.
|
|
149
|
+
let v = ifM[1];
|
|
150
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
|
|
151
|
+
v = v.slice(1, -1);
|
|
152
|
+
}
|
|
153
|
+
cur.if_ = v;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// A step body line at any non-step-header indent — keep accumulating.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (cur) steps.push(cur);
|
|
160
|
+
return steps;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Pure evaluator: does an `if:` expression evaluate to `true` for ANY
|
|
165
|
+
* concrete (platform, arch) tuple in {windows} × {x64, arm64}? If yes,
|
|
166
|
+
* the step is reachable on Windows.
|
|
167
|
+
*
|
|
168
|
+
* Returns true if the expression is unrecognised (fail closed — force
|
|
169
|
+
* the contributor to write a recognisable form or extend the grammar).
|
|
170
|
+
*/
|
|
171
|
+
function reachableOnWindows(ifExpr: string | null, archs: string[]): boolean {
|
|
172
|
+
if (ifExpr == null || ifExpr === "") return true;
|
|
173
|
+
for (const arch of archs) {
|
|
174
|
+
for (const plat of WINDOWS_PLATFORMS) {
|
|
175
|
+
try {
|
|
176
|
+
if (evaluate(ifExpr, { platform: plat, arch })) return true;
|
|
177
|
+
} catch {
|
|
178
|
+
// Unrecognised — fail closed
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Evaluate a small grammar of `matrix.X op 'literal'` boolean expressions.
|
|
188
|
+
* Throws on unrecognised input.
|
|
189
|
+
*/
|
|
190
|
+
function evaluate(
|
|
191
|
+
expr: string,
|
|
192
|
+
ctx: { platform: string; arch: string },
|
|
193
|
+
): boolean {
|
|
194
|
+
const e = expr.trim();
|
|
195
|
+
if (e === "true") return true;
|
|
196
|
+
if (e === "false") return false;
|
|
197
|
+
|
|
198
|
+
// Negation: `!(...)` — must have matching outer parens
|
|
199
|
+
if (e.startsWith("!(") && e.endsWith(")")) {
|
|
200
|
+
return !evaluate(e.slice(2, -1), ctx);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Strip outer parens
|
|
204
|
+
if (e.startsWith("(") && e.endsWith(")") && balanced(e.slice(1, -1))) {
|
|
205
|
+
return evaluate(e.slice(1, -1), ctx);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Top-level `&&` or `||` — split at the first unparenthesized operator
|
|
209
|
+
const splitAt = (op: string): [string, string] | null => {
|
|
210
|
+
let depth = 0;
|
|
211
|
+
for (let i = 0; i < e.length - op.length + 1; i++) {
|
|
212
|
+
if (e[i] === "(") depth += 1;
|
|
213
|
+
else if (e[i] === ")") depth -= 1;
|
|
214
|
+
else if (depth === 0 && e.slice(i, i + op.length) === op) {
|
|
215
|
+
return [e.slice(0, i).trim(), e.slice(i + op.length).trim()];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
};
|
|
220
|
+
const andSplit = splitAt("&&");
|
|
221
|
+
if (andSplit) return evaluate(andSplit[0], ctx) && evaluate(andSplit[1], ctx);
|
|
222
|
+
const orSplit = splitAt("||");
|
|
223
|
+
if (orSplit) return evaluate(orSplit[0], ctx) || evaluate(orSplit[1], ctx);
|
|
224
|
+
|
|
225
|
+
// Atomic: `matrix.<key> <op> '<literal>'`
|
|
226
|
+
const atom = e.match(
|
|
227
|
+
/^matrix\.(platform|arch)\s*(==|!=)\s*['"]([^'"]+)['"]$/,
|
|
228
|
+
);
|
|
229
|
+
if (atom) {
|
|
230
|
+
const [, key, op, lit] = atom;
|
|
231
|
+
const ctxVal = key === "platform" ? ctx.platform : ctx.arch;
|
|
232
|
+
return op === "==" ? ctxVal === lit : ctxVal !== lit;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
throw new Error(`unrecognised if-expression: ${expr}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function balanced(s: string): boolean {
|
|
239
|
+
let depth = 0;
|
|
240
|
+
for (const c of s) {
|
|
241
|
+
if (c === "(") depth += 1;
|
|
242
|
+
else if (c === ")") depth -= 1;
|
|
243
|
+
if (depth < 0) return false;
|
|
244
|
+
}
|
|
245
|
+
return depth === 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
describe("no `shell: bash` step is reachable on a Windows runner", () => {
|
|
249
|
+
for (const wf of WORKFLOW_FILES) {
|
|
250
|
+
const abs = path.join(REPO_ROOT, wf);
|
|
251
|
+
if (!fs.existsSync(abs)) continue;
|
|
252
|
+
const yaml = fs.readFileSync(abs, "utf8");
|
|
253
|
+
|
|
254
|
+
// Find every job that has a Windows-able matrix.
|
|
255
|
+
const jobMatches = [...yaml.matchAll(/^ ([a-z][a-z0-9-]*):\s*$/gm)];
|
|
256
|
+
const jobNames = jobMatches.map((m) => m[1]);
|
|
257
|
+
|
|
258
|
+
for (const jobName of jobNames) {
|
|
259
|
+
const block = extractJobBlock(yaml, jobName);
|
|
260
|
+
if (!block) continue;
|
|
261
|
+
|
|
262
|
+
const platforms = extractMatrixPlatforms(block);
|
|
263
|
+
const hasWindows = platforms.some((p) => WINDOWS_PLATFORMS.has(p));
|
|
264
|
+
// Also count jobs whose runs-on directly names windows-*
|
|
265
|
+
const directWindows = /runs-on:\s*['"]?windows-[\w.-]+['"]?/.test(block);
|
|
266
|
+
if (!hasWindows && !directWindows) continue;
|
|
267
|
+
|
|
268
|
+
// Compute the matrix archs available; default to ['x64','arm64'].
|
|
269
|
+
const archs = [
|
|
270
|
+
...new Set(
|
|
271
|
+
[...block.matchAll(/arch:\s*['"]?([\w-]+)['"]?/g)].map((m) => m[1]),
|
|
272
|
+
),
|
|
273
|
+
];
|
|
274
|
+
const archList = archs.length > 0 ? archs : ["x64"];
|
|
275
|
+
|
|
276
|
+
const baseLine = yaml.slice(0, yaml.indexOf(block)).split("\n").length;
|
|
277
|
+
const steps = extractSteps(block, baseLine);
|
|
278
|
+
|
|
279
|
+
it(`${wf} — job '${jobName}': no shell:bash steps reachable on Windows`, () => {
|
|
280
|
+
const offenders: string[] = [];
|
|
281
|
+
for (const s of steps) {
|
|
282
|
+
if (s.shell !== "bash") continue;
|
|
283
|
+
if (reachableOnWindows(s.if_, archList)) {
|
|
284
|
+
offenders.push(
|
|
285
|
+
` ${wf}:${s.line} step '${s.name}' uses shell: bash` +
|
|
286
|
+
` and is reachable on Windows (if: ${s.if_ ?? "<none>"})`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (offenders.length > 0) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Found ${offenders.length} bash-on-Windows step(s).\n` +
|
|
293
|
+
offenders.join("\n") +
|
|
294
|
+
`\n\nPort each offender to .mjs (cross-OS) OR split per-OS ` +
|
|
295
|
+
`(bash gated by 'matrix.platform != \\'win32\\'' for POSIX, ` +
|
|
296
|
+
`pwsh for Windows).\n` +
|
|
297
|
+
`See change: eliminate-bash-on-windows-runners.`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
expect(offenders).toEqual([]);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
});
|
|
@@ -72,7 +72,7 @@ const ALLOWLIST: readonly string[] = [
|
|
|
72
72
|
* The scope is intentionally narrow: only the build-time sites that the
|
|
73
73
|
* `register-build-time-tools` change migrated, plus the postinstall
|
|
74
74
|
* scripts that mirror the registry's `bare-import` semantics. Bundle /
|
|
75
|
-
* Docker entrypoint scripts (`bundle-server.
|
|
75
|
+
* Docker entrypoint scripts (`bundle-server.mjs`, `docker-make.sh`,
|
|
76
76
|
* `test-electron-install-inner.sh`, etc.) are NOT in scope: those
|
|
77
77
|
* operate on a known WORKDIR with deterministic node_modules layout
|
|
78
78
|
* inside the build image and are not affected by host-side hoisting.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pin Defect 2's jiti version contract for `shouldUrlWrapEntry()`.
|
|
3
|
+
*
|
|
4
|
+
* The Windows-non-tsx arm in `platform/node-spawn.ts::shouldUrlWrapEntry`
|
|
5
|
+
* assumes the jiti loader is from `@mariozechner/pi-coding-agent@0.70.x`
|
|
6
|
+
* (jiti 2.x with the file:// URL handling fix). Newer pi versions ship
|
|
7
|
+
* a different jiti that breaks this contract.
|
|
8
|
+
*
|
|
9
|
+
* This test ensures:
|
|
10
|
+
* 1. The offline-cacache pin in `packages/electron/offline-packages.json`
|
|
11
|
+
* stays at `0.70.x` (the supported range). A bump elsewhere fires
|
|
12
|
+
* this test and forces the contributor to either:
|
|
13
|
+
* - re-verify the contract on Windows
|
|
14
|
+
* - add a per-jiti-version branch
|
|
15
|
+
* - switch the bundled loader to tsx
|
|
16
|
+
* 2. The `shouldUrlWrapEntry` header comment documents the contract
|
|
17
|
+
* so future contributors discover the constraint at the call site.
|
|
18
|
+
*
|
|
19
|
+
* See change: fix-electron-windows-installer-and-server-bootstrap (Defect 2).
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect } from "vitest";
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import url from "node:url";
|
|
25
|
+
|
|
26
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
27
|
+
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..", "..");
|
|
28
|
+
const OFFLINE_PACKAGES_PATH = path.join(
|
|
29
|
+
REPO_ROOT,
|
|
30
|
+
"packages",
|
|
31
|
+
"electron",
|
|
32
|
+
"offline-packages.json",
|
|
33
|
+
);
|
|
34
|
+
const NODE_SPAWN_PATH = path.join(
|
|
35
|
+
REPO_ROOT,
|
|
36
|
+
"packages",
|
|
37
|
+
"shared",
|
|
38
|
+
"src",
|
|
39
|
+
"platform",
|
|
40
|
+
"node-spawn.ts",
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
describe("jiti version contract for shouldUrlWrapEntry", () => {
|
|
44
|
+
it("offline-packages.json pins @mariozechner/pi-coding-agent at a 0.70.x version", () => {
|
|
45
|
+
const raw = fs.readFileSync(OFFLINE_PACKAGES_PATH, "utf8");
|
|
46
|
+
const manifest = JSON.parse(raw) as {
|
|
47
|
+
packages: { name: string; version: string }[];
|
|
48
|
+
};
|
|
49
|
+
const piEntry = manifest.packages.find(
|
|
50
|
+
(p) => p.name === "@mariozechner/pi-coding-agent",
|
|
51
|
+
);
|
|
52
|
+
if (!piEntry) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"@mariozechner/pi-coding-agent not found in offline-packages.json. " +
|
|
55
|
+
"The offline cacache must include pi-coding-agent. " +
|
|
56
|
+
"See change: fix-electron-windows-installer-and-server-bootstrap (Defect 2).",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (!piEntry.version.startsWith("0.70.")) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`pi-coding-agent pinned at ${piEntry.version}, but ` +
|
|
62
|
+
`shouldUrlWrapEntry()'s Windows-non-tsx arm only supports 0.70.x. ` +
|
|
63
|
+
`Newer jiti versions (e.g. 2.6.5 in pi 0.71.x) misnormalize ` +
|
|
64
|
+
`file:/// URL entries on Windows. Either re-verify the contract, ` +
|
|
65
|
+
`add a per-jiti-version branch in shouldUrlWrapEntry(), or switch ` +
|
|
66
|
+
`the bundled loader to tsx. See change: ` +
|
|
67
|
+
`fix-electron-windows-installer-and-server-bootstrap (Defect 2).`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
expect(piEntry.version).toMatch(/^0\.70\./);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("node-spawn.ts source contains the documented JITI VERSION CONTRACT block", () => {
|
|
74
|
+
const source = fs.readFileSync(NODE_SPAWN_PATH, "utf8");
|
|
75
|
+
|
|
76
|
+
// Contract block markers
|
|
77
|
+
expect(source).toContain("JITI VERSION CONTRACT");
|
|
78
|
+
expect(source).toContain("0.70.x");
|
|
79
|
+
|
|
80
|
+
// Version drift markers (at least one of these identifies the broken jiti)
|
|
81
|
+
const hasVersionDriftMarker =
|
|
82
|
+
source.includes("0.71") || source.includes("2.6.5");
|
|
83
|
+
if (!hasVersionDriftMarker) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
"shouldUrlWrapEntry() docstring is missing the version-drift marker. " +
|
|
86
|
+
"It must mention either '0.71' or '2.6.5' so contributors can " +
|
|
87
|
+
"identify the known-broken jiti versions. See change: " +
|
|
88
|
+
"fix-electron-windows-installer-and-server-bootstrap (Defect 2).",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Remediation guidance markers (at least one)
|
|
93
|
+
const hasRemediationGuidance =
|
|
94
|
+
/re-verify/i.test(source) ||
|
|
95
|
+
/per-version branch/i.test(source) ||
|
|
96
|
+
/per-jiti-version/i.test(source) ||
|
|
97
|
+
/switch.*to tsx/i.test(source);
|
|
98
|
+
if (!hasRemediationGuidance) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"shouldUrlWrapEntry() docstring is missing remediation guidance. " +
|
|
101
|
+
"It must mention at least one of: re-verify, per-version branch, " +
|
|
102
|
+
"or switch to tsx. See change: " +
|
|
103
|
+
"fix-electron-windows-installer-and-server-bootstrap (Defect 2).",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -100,6 +100,17 @@ describe("Protocol message serialization round-trip", () => {
|
|
|
100
100
|
requestId: "req-2",
|
|
101
101
|
cancelled: true,
|
|
102
102
|
},
|
|
103
|
+
// See change: fix-restart-bridge-auto-start-race
|
|
104
|
+
{
|
|
105
|
+
type: "server_restarting",
|
|
106
|
+
reason: "restart",
|
|
107
|
+
quiesceMs: 5000,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
type: "server_restarting",
|
|
111
|
+
reason: "shutdown",
|
|
112
|
+
quiesceMs: 60000,
|
|
113
|
+
},
|
|
103
114
|
];
|
|
104
115
|
|
|
105
116
|
for (const msg of messages) {
|
|
@@ -121,3 +121,95 @@ describe("publish.yml — electron job dependency-graph contract", () => {
|
|
|
121
121
|
expect(m[1]).toBe("false");
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
// ── Prerelease safety contract ───────────────────────────────────────────────────────
|
|
126
|
+
// Prerelease versions (e.g. `0.4.5-rc.1`) MUST publish to npm under the
|
|
127
|
+
// `next` dist-tag and surface as GitHub `prerelease: true` Releases. The
|
|
128
|
+
// single source of truth is the `prepare` job's computed `is_prerelease`
|
|
129
|
+
// output. See change: eliminate-bash-on-windows-runners (D6).
|
|
130
|
+
|
|
131
|
+
describe("publish.yml — prerelease safety contract", () => {
|
|
132
|
+
const yaml = fs.readFileSync(WORKFLOW_PATH, "utf8");
|
|
133
|
+
const prepareBlock = extractJobBlock(yaml, "prepare");
|
|
134
|
+
const publishBlock = extractJobBlock(yaml, "publish");
|
|
135
|
+
const ghReleaseBlock = extractJobBlock(yaml, "github-release");
|
|
136
|
+
|
|
137
|
+
it("prepare job's outputs block declares `is_prerelease`", () => {
|
|
138
|
+
// Match the `outputs:` block under `prepare`. Accept any whitespace
|
|
139
|
+
// alignment after the colon, but the key must be present and wired
|
|
140
|
+
// to a step output.
|
|
141
|
+
const m = prepareBlock.match(/^\s{4}outputs:\s*\n((?:\s{6}\S.*\n)+)/m);
|
|
142
|
+
if (!m) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
"prepare job has no `outputs:` block. Required to expose\n" +
|
|
145
|
+
"`is_prerelease` to downstream jobs. See change:\n" +
|
|
146
|
+
"eliminate-bash-on-windows-runners (D6).\n" +
|
|
147
|
+
"prepare block:\n" +
|
|
148
|
+
prepareBlock,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const block = m[1];
|
|
152
|
+
if (!/is_prerelease:\s*\$\{\{\s*steps\.[A-Za-z_]+\.outputs\.is_prerelease\s*\}\}/.test(block)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
"prepare job's outputs block must declare `is_prerelease` wired to a\n" +
|
|
155
|
+
"step output (e.g. `is_prerelease: ${{ steps.resolve.outputs.is_prerelease }}`).\n" +
|
|
156
|
+
"Without this, downstream `publish` and `github-release` jobs cannot\n" +
|
|
157
|
+
"distinguish prereleases from stable versions. See change:\n" +
|
|
158
|
+
"eliminate-bash-on-windows-runners (D6).\n" +
|
|
159
|
+
"outputs block was:\n" +
|
|
160
|
+
block,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
expect(block).toMatch(/is_prerelease:/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("publish job uses `--tag next` conditionally on is_prerelease", () => {
|
|
167
|
+
// Two requirements:
|
|
168
|
+
// 1. The literal string `--tag next` appears in the publish loop body.
|
|
169
|
+
// 2. There's a guard checking `is_prerelease == "true"` (or the bash
|
|
170
|
+
// equivalent `[ "$PRERELEASE" = "true" ]`).
|
|
171
|
+
if (!/--tag next/.test(publishBlock)) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
"publish job is missing the `--tag next` literal. Prereleases must\n" +
|
|
174
|
+
"publish under the `next` dist-tag so consumers running plain\n" +
|
|
175
|
+
"`npm install <pkg>` keep getting the last stable release. See\n" +
|
|
176
|
+
"change: eliminate-bash-on-windows-runners (D6).",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const hasGuard =
|
|
180
|
+
/is_prerelease\s*==\s*['"]true['"]/.test(publishBlock) ||
|
|
181
|
+
/\[\s*"\$PRERELEASE"\s*=\s*"true"\s*\]/.test(publishBlock) ||
|
|
182
|
+
/PRERELEASE.*=.*"true"/.test(publishBlock);
|
|
183
|
+
if (!hasGuard) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
"publish job uses `--tag next` but lacks the prerelease guard. The\n" +
|
|
186
|
+
"`--tag next` argument MUST be conditional on the `is_prerelease`\n" +
|
|
187
|
+
"output (e.g. `if [ \"$PRERELEASE\" = \"true\" ]; then ...`).\n" +
|
|
188
|
+
"Otherwise stable releases would also publish to `next`. See\n" +
|
|
189
|
+
"change: eliminate-bash-on-windows-runners (D6).",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
expect(publishBlock).toContain("--tag next");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("github-release job sets prerelease from is_prerelease", () => {
|
|
196
|
+
// softprops/action-gh-release accepts `prerelease: <bool>` in its
|
|
197
|
+
// `with:` block. The value MUST be derived from the prepare job's
|
|
198
|
+
// `is_prerelease` output (literal-string comparison required because
|
|
199
|
+
// GitHub Actions stringifies job outputs).
|
|
200
|
+
if (
|
|
201
|
+
!/prerelease:\s*\$\{\{\s*needs\.prepare\.outputs\.is_prerelease\s*==\s*['"]true['"]\s*\}\}/
|
|
202
|
+
.test(ghReleaseBlock)
|
|
203
|
+
) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
"github-release job's `softprops/action-gh-release` step must set\n" +
|
|
206
|
+
"`prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}`\n" +
|
|
207
|
+
"in its `with:` block. Otherwise rc tags surface as stable Releases.\n" +
|
|
208
|
+
"See change: eliminate-bash-on-windows-runners (D6).\n" +
|
|
209
|
+
"github-release block was:\n" +
|
|
210
|
+
ghReleaseBlock,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
expect(ghReleaseBlock).toMatch(/prerelease:.*is_prerelease.*true/);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -726,6 +726,29 @@ export interface RequestRolesBrowserMessage {
|
|
|
726
726
|
* which re-emits as `pi.events.emit(event, { ...params, action, _reply })`.
|
|
727
727
|
* See change: add-extension-ui-modal.
|
|
728
728
|
*/
|
|
729
|
+
/**
|
|
730
|
+
* Browser → server: declares which session a browser is currently displaying
|
|
731
|
+
* (typically when the URL is `/session/:id`). The server uses this to gate
|
|
732
|
+
* unread-trigger evaluation and to clear the unread bit when an unread session
|
|
733
|
+
* is opened. Browsers SHALL re-send `session_view` for the currently-displayed
|
|
734
|
+
* session on every WebSocket reconnect so server-side state stays coherent.
|
|
735
|
+
* See change: session-card-unread-stripes.
|
|
736
|
+
*/
|
|
737
|
+
export interface SessionViewBrowserMessage {
|
|
738
|
+
type: "session_view";
|
|
739
|
+
sessionId: string;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Browser → server: declares the browser is no longer displaying the session
|
|
744
|
+
* (e.g. user navigated away from `/session/:id`).
|
|
745
|
+
* See change: session-card-unread-stripes.
|
|
746
|
+
*/
|
|
747
|
+
export interface SessionUnviewBrowserMessage {
|
|
748
|
+
type: "session_unview";
|
|
749
|
+
sessionId: string;
|
|
750
|
+
}
|
|
751
|
+
|
|
729
752
|
export interface UiManagementBrowserMessage {
|
|
730
753
|
type: "ui_management";
|
|
731
754
|
sessionId: string;
|
|
@@ -775,4 +798,6 @@ export type BrowserToServerMessage =
|
|
|
775
798
|
| RolePresetDeleteBrowserMessage
|
|
776
799
|
| RequestRolesBrowserMessage
|
|
777
800
|
| UiManagementBrowserMessage
|
|
801
|
+
| SessionViewBrowserMessage
|
|
802
|
+
| SessionUnviewBrowserMessage
|
|
778
803
|
| KillProcessBrowserMessage;
|
|
@@ -11,6 +11,39 @@ export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
|
11
11
|
|
|
12
12
|
export type SpawnStrategy = "tmux" | "headless";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Policy applied when a bridge re-registers a session after a dashboard
|
|
16
|
+
* restart (i.e. the `session_register` carries `registerReason: "reattach"`).
|
|
17
|
+
*
|
|
18
|
+
* - `"always"` (default) — unconditionally move the session to the front
|
|
19
|
+
* of `sessionOrder` for its cwd.
|
|
20
|
+
* - `"streaming-only"` — only move-to-front when the session's status is
|
|
21
|
+
* currently `"streaming"`.
|
|
22
|
+
* - `"preserve"` — leave `sessionOrder` untouched (legacy behavior).
|
|
23
|
+
*
|
|
24
|
+
* See change: reattach-move-to-front.
|
|
25
|
+
*/
|
|
26
|
+
export type ReattachPlacement = "preserve" | "streaming-only" | "always";
|
|
27
|
+
|
|
28
|
+
const VALID_REATTACH_PLACEMENTS: ReattachPlacement[] = [
|
|
29
|
+
"preserve",
|
|
30
|
+
"streaming-only",
|
|
31
|
+
"always",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_REATTACH_PLACEMENT: ReattachPlacement = "always";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate a raw value against the {@link ReattachPlacement} union.
|
|
38
|
+
* Anything outside the union (including `undefined`, numbers, objects)
|
|
39
|
+
* falls back to {@link DEFAULT_REATTACH_PLACEMENT}.
|
|
40
|
+
*/
|
|
41
|
+
export function parseReattachPlacement(raw: unknown): ReattachPlacement {
|
|
42
|
+
return typeof raw === "string" && (VALID_REATTACH_PLACEMENTS as string[]).includes(raw)
|
|
43
|
+
? (raw as ReattachPlacement)
|
|
44
|
+
: DEFAULT_REATTACH_PLACEMENT;
|
|
45
|
+
}
|
|
46
|
+
|
|
14
47
|
export interface AuthProviderConfig {
|
|
15
48
|
clientId: string;
|
|
16
49
|
clientSecret: string;
|
|
@@ -111,6 +144,12 @@ export interface DashboardConfig {
|
|
|
111
144
|
lastServer?: string;
|
|
112
145
|
/** Whether the server was launched by the Electron app */
|
|
113
146
|
electronMode: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* Policy applied when the bridge reattaches after a dashboard restart.
|
|
149
|
+
* See {@link ReattachPlacement}. Default `"always"`.
|
|
150
|
+
* See change: reattach-move-to-front.
|
|
151
|
+
*/
|
|
152
|
+
reattachPlacement: ReattachPlacement;
|
|
114
153
|
/** Persisted list of known remote servers */
|
|
115
154
|
knownServers: KnownServer[];
|
|
116
155
|
/**
|
|
@@ -148,6 +187,7 @@ const DEFAULTS: DashboardConfig = {
|
|
|
148
187
|
cors: { allowedOrigins: [] },
|
|
149
188
|
electronMode: false,
|
|
150
189
|
knownServers: [],
|
|
190
|
+
reattachPlacement: DEFAULT_REATTACH_PLACEMENT,
|
|
151
191
|
};
|
|
152
192
|
|
|
153
193
|
/**
|
|
@@ -343,6 +383,7 @@ export function loadConfig(): DashboardConfig {
|
|
|
343
383
|
...(typeof parsed.lastServer === "string" ? { lastServer: parsed.lastServer } : {}),
|
|
344
384
|
electronMode: parsed.electronMode === true,
|
|
345
385
|
knownServers: parseKnownServers(parsed.knownServers),
|
|
386
|
+
reattachPlacement: parseReattachPlacement(parsed.reattachPlacement),
|
|
346
387
|
plugins: parsePluginsConfig(parsed.plugins),
|
|
347
388
|
};
|
|
348
389
|
|
|
@@ -103,10 +103,41 @@ function getLocalAddresses(): Set<string> {
|
|
|
103
103
|
return addresses;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Pick the best host string for a discovered service.
|
|
108
|
+
*
|
|
109
|
+
* Bonjour can advertise `service.host` as the OS computer-name (e.g. macOS
|
|
110
|
+
* "MacBook 242") which contains characters that are not valid in a DNS
|
|
111
|
+
* hostname — so the browser cannot resolve it. When that happens we fall back
|
|
112
|
+
* to `service.addresses` (preferring IPv4 over IPv6) so the saved entry is
|
|
113
|
+
* actually reachable.
|
|
114
|
+
*
|
|
115
|
+
* Rule: a host is DNS-safe iff it matches `[A-Za-z0-9.-]+` and does not
|
|
116
|
+
* begin or end with a hyphen (RFC 1123, relaxed for the `.local` suffix).
|
|
117
|
+
*/
|
|
118
|
+
export function pickBestHost(service: Pick<Service, "host" | "addresses">): string {
|
|
119
|
+
const host = service.host;
|
|
120
|
+
const isDnsSafe =
|
|
121
|
+
typeof host === "string" &&
|
|
122
|
+
host.length > 0 &&
|
|
123
|
+
/^[A-Za-z0-9.-]+$/.test(host) &&
|
|
124
|
+
!host.startsWith("-") &&
|
|
125
|
+
!host.endsWith("-");
|
|
126
|
+
if (isDnsSafe) return host;
|
|
127
|
+
|
|
128
|
+
const addresses = service.addresses ?? [];
|
|
129
|
+
const ipv4 = addresses.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a));
|
|
130
|
+
if (ipv4) return ipv4;
|
|
131
|
+
if (addresses.length > 0) return addresses[0];
|
|
132
|
+
|
|
133
|
+
// Last-resort: keep the original host so we never return undefined.
|
|
134
|
+
return host ?? "unknown";
|
|
135
|
+
}
|
|
136
|
+
|
|
106
137
|
function serviceToServer(service: Service, isLocal: boolean): DiscoveredServer {
|
|
107
138
|
const txt = service.txt as Record<string, string> | undefined;
|
|
108
139
|
return {
|
|
109
|
-
host: service
|
|
140
|
+
host: pickBestHost(service),
|
|
110
141
|
port: service.port,
|
|
111
142
|
piPort: parseInt(txt?.piPort ?? "9999", 10),
|
|
112
143
|
version: txt?.version ?? "unknown",
|