@blackbelt-technology/pi-agent-dashboard 0.5.1 → 0.5.2
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 +26 -5
- package/README.md +30 -0
- package/docs/architecture.md +129 -1
- package/package.json +6 -6
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/bridge-context.ts +67 -3
- package/packages/extension/src/bridge.ts +20 -8
- package/packages/extension/src/command-handler.ts +36 -13
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +6 -5
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +15 -7
- package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
- package/packages/server/src/cli.ts +61 -81
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +31 -1
- package/packages/server/src/headless-pid-registry.ts +299 -41
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/process-manager.ts +128 -0
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/provider-auth-routes.ts +3 -0
- package/packages/server/src/routes/provider-routes.ts +24 -1
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +178 -2
- package/packages/server/src/session-api.ts +9 -1
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +27 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -5
- package/packages/shared/src/protocol.ts +19 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/types.ts +55 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
- package/packages/shared/src/resolve-jiti.ts +0 -155
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the OpenSpec change-grouping store.
|
|
3
|
+
*
|
|
4
|
+
* Covers tasks 2.15–2.17 + spec scenarios from
|
|
5
|
+
* `openspec/changes/add-openspec-change-grouping/specs/openspec-change-grouping/spec.md`.
|
|
6
|
+
*
|
|
7
|
+
* See change: add-openspec-change-grouping.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
10
|
+
import fs from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import {
|
|
14
|
+
createOpenSpecGroupStore,
|
|
15
|
+
ConcurrentEditError,
|
|
16
|
+
UnsupportedSchemaVersionError,
|
|
17
|
+
GroupNotFoundError,
|
|
18
|
+
UnknownGroupIdError,
|
|
19
|
+
type OpenSpecGroupStore,
|
|
20
|
+
} from "../openspec-group-store.js";
|
|
21
|
+
import { OPENSPEC_GROUPS_SCHEMA_VERSION } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
22
|
+
|
|
23
|
+
describe("openspec-group-store", () => {
|
|
24
|
+
let tmpDir: string;
|
|
25
|
+
let cwd: string;
|
|
26
|
+
let groupsFile: string;
|
|
27
|
+
let store: OpenSpecGroupStore;
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ogs-"));
|
|
31
|
+
cwd = tmpDir;
|
|
32
|
+
groupsFile = path.join(cwd, "openspec", "groups", "groups.json");
|
|
33
|
+
store = createOpenSpecGroupStore();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
store.dispose();
|
|
38
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ── Read path ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe("read()", () => {
|
|
44
|
+
it("returns the default-empty payload when the file is absent and does NOT create the directory", async () => {
|
|
45
|
+
const data = await store.read(cwd);
|
|
46
|
+
expect(data).toEqual({ schemaVersion: 1, groups: [], assignments: {} });
|
|
47
|
+
|
|
48
|
+
const dirExists = await fs.stat(path.join(cwd, "openspec", "groups")).then(
|
|
49
|
+
() => true,
|
|
50
|
+
() => false,
|
|
51
|
+
);
|
|
52
|
+
expect(dirExists).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns parsed contents verbatim when file present", async () => {
|
|
56
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
57
|
+
await fs.writeFile(
|
|
58
|
+
groupsFile,
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
groups: [{ id: "ui", name: "UI", color: "#3b82f6", order: 0 }],
|
|
62
|
+
assignments: { "add-foo": "ui" },
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
const data = await store.read(cwd);
|
|
66
|
+
expect(data.groups).toEqual([{ id: "ui", name: "UI", color: "#3b82f6", order: 0 }]);
|
|
67
|
+
expect(data.assignments).toEqual({ "add-foo": "ui" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns cached data on unchanged (mtime, size) without calling readFile", async () => {
|
|
71
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
72
|
+
await fs.writeFile(
|
|
73
|
+
groupsFile,
|
|
74
|
+
JSON.stringify({ schemaVersion: 1, groups: [], assignments: {} }),
|
|
75
|
+
);
|
|
76
|
+
// Prime the cache.
|
|
77
|
+
await store.read(cwd);
|
|
78
|
+
const readSpy = vi.spyOn(fs, "readFile");
|
|
79
|
+
// Second read should be a cache hit — no readFile.
|
|
80
|
+
await store.read(cwd);
|
|
81
|
+
await store.read(cwd);
|
|
82
|
+
expect(readSpy).not.toHaveBeenCalled();
|
|
83
|
+
readSpy.mockRestore();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("re-reads when mtime changes", async () => {
|
|
87
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
88
|
+
await fs.writeFile(
|
|
89
|
+
groupsFile,
|
|
90
|
+
JSON.stringify({ schemaVersion: 1, groups: [], assignments: {} }),
|
|
91
|
+
);
|
|
92
|
+
const first = await store.read(cwd);
|
|
93
|
+
expect(first.groups).toHaveLength(0);
|
|
94
|
+
// Bump mtime and content.
|
|
95
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
96
|
+
const future = new Date(Date.now() + 60_000);
|
|
97
|
+
await fs.writeFile(
|
|
98
|
+
groupsFile,
|
|
99
|
+
JSON.stringify({
|
|
100
|
+
schemaVersion: 1,
|
|
101
|
+
groups: [{ id: "ui", name: "UI", order: 0 }],
|
|
102
|
+
assignments: {},
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
await fs.utimes(groupsFile, future, future);
|
|
106
|
+
const second = await store.read(cwd);
|
|
107
|
+
expect(second.groups).toHaveLength(1);
|
|
108
|
+
expect(second.groups[0]?.id).toBe("ui");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("detects within-same-second writes via size delta", async () => {
|
|
112
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
113
|
+
const base = { schemaVersion: 1, groups: [], assignments: {} };
|
|
114
|
+
await fs.writeFile(groupsFile, JSON.stringify(base));
|
|
115
|
+
const stat1 = await fs.stat(groupsFile);
|
|
116
|
+
await store.read(cwd);
|
|
117
|
+
// Write with same mtime but larger size.
|
|
118
|
+
const bigger = {
|
|
119
|
+
schemaVersion: 1,
|
|
120
|
+
groups: [{ id: "ui", name: "UI", order: 0 }],
|
|
121
|
+
assignments: {},
|
|
122
|
+
};
|
|
123
|
+
await fs.writeFile(groupsFile, JSON.stringify(bigger));
|
|
124
|
+
await fs.utimes(groupsFile, stat1.atime, stat1.mtime); // force same mtime
|
|
125
|
+
const data = await store.read(cwd);
|
|
126
|
+
expect(data.groups).toHaveLength(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("clears the cache and returns default when the file is deleted", async () => {
|
|
130
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
131
|
+
await fs.writeFile(
|
|
132
|
+
groupsFile,
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
schemaVersion: 1,
|
|
135
|
+
groups: [{ id: "ui", name: "UI", order: 0 }],
|
|
136
|
+
assignments: {},
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
const first = await store.read(cwd);
|
|
140
|
+
expect(first.groups).toHaveLength(1);
|
|
141
|
+
await fs.rm(groupsFile);
|
|
142
|
+
const second = await store.read(cwd);
|
|
143
|
+
expect(second).toEqual({ schemaVersion: 1, groups: [], assignments: {} });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("shares one readFile call across concurrent reads in the same tick", async () => {
|
|
147
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
148
|
+
await fs.writeFile(
|
|
149
|
+
groupsFile,
|
|
150
|
+
JSON.stringify({ schemaVersion: 1, groups: [], assignments: {} }),
|
|
151
|
+
);
|
|
152
|
+
const readSpy = vi.spyOn(fs, "readFile");
|
|
153
|
+
const reads = await Promise.all([store.read(cwd), store.read(cwd), store.read(cwd), store.read(cwd), store.read(cwd)]);
|
|
154
|
+
expect(reads.every((r) => r.schemaVersion === 1)).toBe(true);
|
|
155
|
+
// First batch: 1 readFile call shared across 5 reads.
|
|
156
|
+
expect(readSpy).toHaveBeenCalledTimes(1);
|
|
157
|
+
readSpy.mockRestore();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rejects unknown schemaVersion with UnsupportedSchemaVersionError", async () => {
|
|
161
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
162
|
+
await fs.writeFile(groupsFile, JSON.stringify({ schemaVersion: 2, groups: [], assignments: {} }));
|
|
163
|
+
await expect(store.read(cwd)).rejects.toBeInstanceOf(UnsupportedSchemaVersionError);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("rejects missing schemaVersion field with UnsupportedSchemaVersionError", async () => {
|
|
167
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
168
|
+
await fs.writeFile(groupsFile, JSON.stringify({ groups: [], assignments: {} }));
|
|
169
|
+
await expect(store.read(cwd)).rejects.toBeInstanceOf(UnsupportedSchemaVersionError);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── createGroup ──────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
describe("createGroup()", () => {
|
|
176
|
+
it("creates the file + directory on first write", async () => {
|
|
177
|
+
const g = await store.createGroup(cwd, { name: "UI", color: "#3b82f6" });
|
|
178
|
+
expect(g.id).toBe("ui");
|
|
179
|
+
expect(g.name).toBe("UI");
|
|
180
|
+
expect(g.order).toBe(0);
|
|
181
|
+
const raw = await fs.readFile(groupsFile, "utf-8");
|
|
182
|
+
const parsed = JSON.parse(raw);
|
|
183
|
+
expect(parsed.schemaVersion).toBe(OPENSPEC_GROUPS_SCHEMA_VERSION);
|
|
184
|
+
expect(parsed.groups).toHaveLength(1);
|
|
185
|
+
expect(parsed.assignments).toEqual({});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("appends collision suffix when slug already exists", async () => {
|
|
189
|
+
await store.createGroup(cwd, { name: "UI" });
|
|
190
|
+
const second = await store.createGroup(cwd, { name: "UI" });
|
|
191
|
+
expect(second.id).toBe("ui-2");
|
|
192
|
+
const third = await store.createGroup(cwd, { name: "UI" });
|
|
193
|
+
expect(third.id).toBe("ui-3");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("auto-assigns order = groups.length", async () => {
|
|
197
|
+
const a = await store.createGroup(cwd, { name: "UI" });
|
|
198
|
+
const b = await store.createGroup(cwd, { name: "Server" });
|
|
199
|
+
expect(a.order).toBe(0);
|
|
200
|
+
expect(b.order).toBe(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("serializes concurrent creates for the same cwd via FIFO mutex", async () => {
|
|
204
|
+
const [a, b, c] = await Promise.all([
|
|
205
|
+
store.createGroup(cwd, { name: "A" }),
|
|
206
|
+
store.createGroup(cwd, { name: "B" }),
|
|
207
|
+
store.createGroup(cwd, { name: "C" }),
|
|
208
|
+
]);
|
|
209
|
+
const data = await store.read(cwd);
|
|
210
|
+
expect(data.groups).toHaveLength(3);
|
|
211
|
+
const ids = new Set(data.groups.map((g) => g.id));
|
|
212
|
+
expect(ids.has(a.id)).toBe(true);
|
|
213
|
+
expect(ids.has(b.id)).toBe(true);
|
|
214
|
+
expect(ids.has(c.id)).toBe(true);
|
|
215
|
+
// Orders are contiguous 0..2.
|
|
216
|
+
const orders = data.groups.map((g) => g.order).sort();
|
|
217
|
+
expect(orders).toEqual([0, 1, 2]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── updateGroup ──────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe("updateGroup()", () => {
|
|
224
|
+
it("renames without changing id", async () => {
|
|
225
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
226
|
+
const updated = await store.updateGroup(cwd, g.id, { name: "Frontend" });
|
|
227
|
+
expect(updated.id).toBe("ui");
|
|
228
|
+
expect(updated.name).toBe("Frontend");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("throws GroupNotFoundError on unknown id", async () => {
|
|
232
|
+
await expect(store.updateGroup(cwd, "does-not-exist", { name: "X" })).rejects.toBeInstanceOf(
|
|
233
|
+
GroupNotFoundError,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("normalizes order to contiguous 0..N-1 after a reorder", async () => {
|
|
238
|
+
const a = await store.createGroup(cwd, { name: "A" }); // order 0
|
|
239
|
+
const b = await store.createGroup(cwd, { name: "B" }); // order 1
|
|
240
|
+
const c = await store.createGroup(cwd, { name: "C" }); // order 2
|
|
241
|
+
// Move A to the end.
|
|
242
|
+
await store.updateGroup(cwd, a.id, { order: 5 });
|
|
243
|
+
const data = await store.read(cwd);
|
|
244
|
+
const byId = new Map(data.groups.map((g) => [g.id, g.order]));
|
|
245
|
+
const orders = [...byId.values()].sort();
|
|
246
|
+
expect(orders).toEqual([0, 1, 2]);
|
|
247
|
+
// A should be last (highest order).
|
|
248
|
+
expect(byId.get(a.id)).toBe(2);
|
|
249
|
+
expect(byId.get(b.id)).toBe(0);
|
|
250
|
+
expect(byId.get(c.id)).toBe(1);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── deleteGroup ──────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
describe("deleteGroup()", () => {
|
|
257
|
+
it("removes the group", async () => {
|
|
258
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
259
|
+
await store.deleteGroup(cwd, g.id);
|
|
260
|
+
const data = await store.read(cwd);
|
|
261
|
+
expect(data.groups).toHaveLength(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("cascades through assignments referencing the deleted group", async () => {
|
|
265
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
266
|
+
const other = await store.createGroup(cwd, { name: "Server" });
|
|
267
|
+
await store.setAssignment(cwd, "add-foo", g.id);
|
|
268
|
+
await store.setAssignment(cwd, "fix-bar", other.id);
|
|
269
|
+
await store.deleteGroup(cwd, g.id);
|
|
270
|
+
const data = await store.read(cwd);
|
|
271
|
+
expect(data.assignments).toEqual({ "fix-bar": other.id });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("throws GroupNotFoundError on unknown id", async () => {
|
|
275
|
+
await expect(store.deleteGroup(cwd, "does-not-exist")).rejects.toBeInstanceOf(GroupNotFoundError);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("re-packs orders contiguously after delete", async () => {
|
|
279
|
+
const a = await store.createGroup(cwd, { name: "A" });
|
|
280
|
+
const b = await store.createGroup(cwd, { name: "B" });
|
|
281
|
+
const c = await store.createGroup(cwd, { name: "C" });
|
|
282
|
+
await store.deleteGroup(cwd, b.id);
|
|
283
|
+
const data = await store.read(cwd);
|
|
284
|
+
const byId = new Map(data.groups.map((g) => [g.id, g.order]));
|
|
285
|
+
// Surviving orders should be contiguous 0..1.
|
|
286
|
+
const orders = [...byId.values()].sort();
|
|
287
|
+
expect(orders).toEqual([0, 1]);
|
|
288
|
+
expect(byId.get(a.id)).toBe(0);
|
|
289
|
+
expect(byId.get(c.id)).toBe(1);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ── setAssignment ────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
describe("setAssignment()", () => {
|
|
296
|
+
it("adds a single map entry", async () => {
|
|
297
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
298
|
+
await store.setAssignment(cwd, "add-foo", g.id);
|
|
299
|
+
const data = await store.read(cwd);
|
|
300
|
+
expect(data.assignments).toEqual({ "add-foo": g.id });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("replaces previous group on reassignment", async () => {
|
|
304
|
+
const g1 = await store.createGroup(cwd, { name: "UI" });
|
|
305
|
+
const g2 = await store.createGroup(cwd, { name: "Server" });
|
|
306
|
+
await store.setAssignment(cwd, "add-foo", g1.id);
|
|
307
|
+
await store.setAssignment(cwd, "add-foo", g2.id);
|
|
308
|
+
const data = await store.read(cwd);
|
|
309
|
+
expect(data.assignments).toEqual({ "add-foo": g2.id });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("removes the entry on null", async () => {
|
|
313
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
314
|
+
await store.setAssignment(cwd, "add-foo", g.id);
|
|
315
|
+
await store.setAssignment(cwd, "add-foo", null);
|
|
316
|
+
const data = await store.read(cwd);
|
|
317
|
+
expect(data.assignments).toEqual({});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("throws UnknownGroupIdError on unknown groupId", async () => {
|
|
321
|
+
await expect(store.setAssignment(cwd, "add-foo", "does-not-exist")).rejects.toBeInstanceOf(
|
|
322
|
+
UnknownGroupIdError,
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("tolerates unknown changeName", async () => {
|
|
327
|
+
const g = await store.createGroup(cwd, { name: "UI" });
|
|
328
|
+
await store.setAssignment(cwd, "never-existed", g.id);
|
|
329
|
+
const data = await store.read(cwd);
|
|
330
|
+
expect(data.assignments).toEqual({ "never-existed": g.id });
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ── Concurrency: FIFO mutex + cwd isolation ──────────────────
|
|
335
|
+
|
|
336
|
+
describe("concurrency", () => {
|
|
337
|
+
it("two concurrent writes to the same cwd produce both results (no lost write)", async () => {
|
|
338
|
+
// Both should serialize and both succeed.
|
|
339
|
+
const [a, b] = await Promise.all([
|
|
340
|
+
store.createGroup(cwd, { name: "A" }),
|
|
341
|
+
store.createGroup(cwd, { name: "B" }),
|
|
342
|
+
]);
|
|
343
|
+
const data = await store.read(cwd);
|
|
344
|
+
const ids = new Set(data.groups.map((g) => g.id));
|
|
345
|
+
expect(ids.has(a.id)).toBe(true);
|
|
346
|
+
expect(ids.has(b.id)).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("concurrent writes to different cwds proceed in parallel", async () => {
|
|
350
|
+
const tmpDir2 = await fs.mkdtemp(path.join(os.tmpdir(), "ogs2-"));
|
|
351
|
+
try {
|
|
352
|
+
const [a, b] = await Promise.all([
|
|
353
|
+
store.createGroup(cwd, { name: "A" }),
|
|
354
|
+
store.createGroup(tmpDir2, { name: "B" }),
|
|
355
|
+
]);
|
|
356
|
+
expect(a.id).toBe("a");
|
|
357
|
+
expect(b.id).toBe("b");
|
|
358
|
+
} finally {
|
|
359
|
+
await fs.rm(tmpDir2, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ── 1-shot retry on hand-edit race ───────────────────────────
|
|
365
|
+
|
|
366
|
+
describe("hand-edit race detection", () => {
|
|
367
|
+
it("retries once and succeeds when the file is hand-edited between read and rename", async () => {
|
|
368
|
+
let triggered = false;
|
|
369
|
+
const racingStore = createOpenSpecGroupStore({
|
|
370
|
+
__testHookBeforeRename: async () => {
|
|
371
|
+
if (triggered) return;
|
|
372
|
+
triggered = true;
|
|
373
|
+
// Simulate a hand-edit: write a different version of the file.
|
|
374
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
375
|
+
const future = new Date(Date.now() + 60_000);
|
|
376
|
+
await fs.writeFile(
|
|
377
|
+
groupsFile,
|
|
378
|
+
JSON.stringify({
|
|
379
|
+
schemaVersion: 1,
|
|
380
|
+
groups: [{ id: "external", name: "External", order: 0 }],
|
|
381
|
+
assignments: {},
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
await fs.utimes(groupsFile, future, future);
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
try {
|
|
388
|
+
const created = await racingStore.createGroup(cwd, { name: "UI" });
|
|
389
|
+
expect(created.name).toBe("UI");
|
|
390
|
+
// Final file should contain BOTH the external group and the dashboard one.
|
|
391
|
+
const data = await racingStore.read(cwd);
|
|
392
|
+
const ids = new Set(data.groups.map((g) => g.id));
|
|
393
|
+
expect(ids.has("external")).toBe(true);
|
|
394
|
+
expect(ids.has(created.id)).toBe(true);
|
|
395
|
+
} finally {
|
|
396
|
+
racingStore.dispose();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("throws ConcurrentEditError after a sustained race (two strikes)", async () => {
|
|
401
|
+
let strikes = 0;
|
|
402
|
+
const racingStore = createOpenSpecGroupStore({
|
|
403
|
+
__testHookBeforeRename: async () => {
|
|
404
|
+
strikes++;
|
|
405
|
+
// Always race — simulate sustained external editor.
|
|
406
|
+
await fs.mkdir(path.dirname(groupsFile), { recursive: true });
|
|
407
|
+
const future = new Date(Date.now() + 60_000 * strikes);
|
|
408
|
+
await fs.writeFile(
|
|
409
|
+
groupsFile,
|
|
410
|
+
JSON.stringify({
|
|
411
|
+
schemaVersion: 1,
|
|
412
|
+
groups: [{ id: "external", name: `External-${strikes}`, order: 0 }],
|
|
413
|
+
assignments: {},
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
await fs.utimes(groupsFile, future, future);
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
try {
|
|
420
|
+
await expect(racingStore.createGroup(cwd, { name: "UI" })).rejects.toBeInstanceOf(
|
|
421
|
+
ConcurrentEditError,
|
|
422
|
+
);
|
|
423
|
+
// Hook fires twice: once for the original write, once for the retry.
|
|
424
|
+
expect(strikes).toBe(2);
|
|
425
|
+
} finally {
|
|
426
|
+
racingStore.dispose();
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// ── Subscribe / debounced broadcast ──────────────────────────
|
|
432
|
+
|
|
433
|
+
describe("subscribe() — debounced broadcast", () => {
|
|
434
|
+
it("emits a single broadcast for one write within the debounce window", async () => {
|
|
435
|
+
vi.useFakeTimers();
|
|
436
|
+
const fast = createOpenSpecGroupStore({ debounceMs: 100 });
|
|
437
|
+
const events: Array<{ cwd: string; payload: unknown }> = [];
|
|
438
|
+
fast.subscribe((cwd, payload) => events.push({ cwd, payload }));
|
|
439
|
+
try {
|
|
440
|
+
await fast.createGroup(cwd, { name: "UI" });
|
|
441
|
+
// Before the debounce window elapses, no broadcast yet.
|
|
442
|
+
expect(events).toHaveLength(0);
|
|
443
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
444
|
+
expect(events).toHaveLength(1);
|
|
445
|
+
expect(events[0]?.cwd).toBe(cwd);
|
|
446
|
+
} finally {
|
|
447
|
+
vi.useRealTimers();
|
|
448
|
+
fast.dispose();
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("coalesces 5 writes within 100 ms into 1 broadcast", async () => {
|
|
453
|
+
vi.useFakeTimers();
|
|
454
|
+
const fast = createOpenSpecGroupStore({ debounceMs: 100 });
|
|
455
|
+
const events: Array<{ cwd: string; payload: { groups: Array<{ id: string }> } }> = [];
|
|
456
|
+
fast.subscribe((c, p) => events.push({ cwd: c, payload: p as any }));
|
|
457
|
+
try {
|
|
458
|
+
await fast.createGroup(cwd, { name: "A" });
|
|
459
|
+
await fast.createGroup(cwd, { name: "B" });
|
|
460
|
+
await fast.createGroup(cwd, { name: "C" });
|
|
461
|
+
await fast.createGroup(cwd, { name: "D" });
|
|
462
|
+
await fast.createGroup(cwd, { name: "E" });
|
|
463
|
+
// Still inside debounce window.
|
|
464
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
465
|
+
expect(events).toHaveLength(0);
|
|
466
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
467
|
+
expect(events).toHaveLength(1);
|
|
468
|
+
expect(events[0]?.payload.groups).toHaveLength(5);
|
|
469
|
+
} finally {
|
|
470
|
+
vi.useRealTimers();
|
|
471
|
+
fast.dispose();
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("does NOT coalesce broadcasts across different cwds", async () => {
|
|
476
|
+
const tmpDir2 = await fs.mkdtemp(path.join(os.tmpdir(), "ogs2-"));
|
|
477
|
+
vi.useFakeTimers();
|
|
478
|
+
const fast = createOpenSpecGroupStore({ debounceMs: 100 });
|
|
479
|
+
const events: Array<{ cwd: string }> = [];
|
|
480
|
+
fast.subscribe((c) => events.push({ cwd: c }));
|
|
481
|
+
try {
|
|
482
|
+
await fast.createGroup(cwd, { name: "A" });
|
|
483
|
+
await fast.createGroup(tmpDir2, { name: "B" });
|
|
484
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
485
|
+
expect(events).toHaveLength(2);
|
|
486
|
+
const cwds = new Set(events.map((e) => e.cwd));
|
|
487
|
+
expect(cwds.has(cwd)).toBe(true);
|
|
488
|
+
expect(cwds.has(tmpDir2)).toBe(true);
|
|
489
|
+
} finally {
|
|
490
|
+
vi.useRealTimers();
|
|
491
|
+
fast.dispose();
|
|
492
|
+
await fs.rm(tmpDir2, { recursive: true, force: true });
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Precondition test: pi-ai symbol shape.
|
|
3
|
+
*
|
|
4
|
+
* Runtime-resolves pi-ai via the ToolRegistry and asserts every symbol
|
|
5
|
+
* the model-proxy change depends on exists in the resolved module.
|
|
6
|
+
*
|
|
7
|
+
* - `it.skip` when pi-ai cannot be resolved (clean CI without ~/.pi-dashboard/).
|
|
8
|
+
* - Full run when pi-ai is installed locally.
|
|
9
|
+
* - Set `MODEL_PROXY_REQUIRE_PI_AI=1` to force hard-fail (for release-cut runs).
|
|
10
|
+
*
|
|
11
|
+
* Run locally:
|
|
12
|
+
* MODEL_PROXY_REQUIRE_PI_AI=1 npm test -- pi-ai-shape
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
15
|
+
import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
|
|
16
|
+
|
|
17
|
+
const REQUIRE = process.env.MODEL_PROXY_REQUIRE_PI_AI === "1";
|
|
18
|
+
|
|
19
|
+
let piAi: Record<string, unknown> | null = null;
|
|
20
|
+
let piAiOAuth: Record<string, unknown> | null = null;
|
|
21
|
+
let resolveError: Error | null = null;
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
try {
|
|
25
|
+
const result = await getDefaultRegistry().resolveModule<Record<string, unknown>>("pi-ai");
|
|
26
|
+
piAi = result.module;
|
|
27
|
+
|
|
28
|
+
// Resolve oauth subpath — pi-ai exports it from dist/oauth.js
|
|
29
|
+
const resolution = result.resolution;
|
|
30
|
+
if (resolution.path) {
|
|
31
|
+
const oauthPath = resolution.path.replace(/\/dist\/index\.js$/, "/dist/oauth.js");
|
|
32
|
+
try {
|
|
33
|
+
const { pathToFileURL } = await import("node:url");
|
|
34
|
+
piAiOAuth = (await import(pathToFileURL(oauthPath).href)) as Record<string, unknown>;
|
|
35
|
+
} catch {
|
|
36
|
+
// OAuth subpath may not exist in all versions
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
resolveError = err as Error;
|
|
41
|
+
if (REQUIRE) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`MODEL_PROXY_REQUIRE_PI_AI=1 but pi-ai could not be resolved: ${(err as Error).message}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const skipOrRun = () => {
|
|
50
|
+
if (!piAi) return it.skip;
|
|
51
|
+
return it;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
describe("pi-ai shape precondition", () => {
|
|
55
|
+
it("resolves pi-ai or skips gracefully", () => {
|
|
56
|
+
if (REQUIRE) {
|
|
57
|
+
expect(piAi).not.toBeNull();
|
|
58
|
+
} else if (!piAi) {
|
|
59
|
+
console.log(`pi-ai not resolved (${resolveError?.message}); skipping shape checks`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// --- Main exports from pi-ai (dist/index.js) ---
|
|
64
|
+
|
|
65
|
+
describe("main exports", () => {
|
|
66
|
+
it("exports streamSimple", () => {
|
|
67
|
+
if (!piAi) return;
|
|
68
|
+
expect(typeof piAi.streamSimple).toBe("function");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("exports getModels", () => {
|
|
72
|
+
if (!piAi) return;
|
|
73
|
+
expect(typeof piAi.getModels).toBe("function");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("exports registerBuiltInApiProviders", () => {
|
|
77
|
+
if (!piAi) return;
|
|
78
|
+
expect(typeof piAi.registerBuiltInApiProviders).toBe("function");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("exports getApiProvider", () => {
|
|
82
|
+
if (!piAi) return;
|
|
83
|
+
expect(typeof piAi.getApiProvider).toBe("function");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("exports getProviders", () => {
|
|
87
|
+
if (!piAi) return;
|
|
88
|
+
expect(typeof piAi.getProviders).toBe("function");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("exports registerApiProvider", () => {
|
|
92
|
+
if (!piAi) return;
|
|
93
|
+
expect(typeof piAi.registerApiProvider).toBe("function");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("exports getModel", () => {
|
|
97
|
+
if (!piAi) return;
|
|
98
|
+
expect(typeof piAi.getModel).toBe("function");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("exports registerFauxProvider (for testing)", () => {
|
|
102
|
+
if (!piAi) return;
|
|
103
|
+
expect(typeof piAi.registerFauxProvider).toBe("function");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("exports fauxText / fauxThinking / fauxToolCall helpers", () => {
|
|
107
|
+
if (!piAi) return;
|
|
108
|
+
expect(typeof piAi.fauxText).toBe("function");
|
|
109
|
+
expect(typeof piAi.fauxThinking).toBe("function");
|
|
110
|
+
expect(typeof piAi.fauxToolCall).toBe("function");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// --- OAuth exports from pi-ai/oauth (dist/oauth.js) ---
|
|
115
|
+
|
|
116
|
+
describe("oauth exports", () => {
|
|
117
|
+
it("exports refreshAnthropicToken (Anthropic OAuth)", () => {
|
|
118
|
+
if (!piAiOAuth) return;
|
|
119
|
+
expect(typeof piAiOAuth.refreshAnthropicToken).toBe("function");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("exports refreshOpenAICodexToken (Codex OAuth)", () => {
|
|
123
|
+
if (!piAiOAuth) return;
|
|
124
|
+
expect(typeof piAiOAuth.refreshOpenAICodexToken).toBe("function");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("exports refreshGitHubCopilotToken (GitHub Copilot OAuth)", () => {
|
|
128
|
+
if (!piAiOAuth) return;
|
|
129
|
+
expect(typeof piAiOAuth.refreshGitHubCopilotToken).toBe("function");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("exports getOAuthProvider (generic provider lookup)", () => {
|
|
133
|
+
if (!piAiOAuth) return;
|
|
134
|
+
expect(typeof piAiOAuth.getOAuthProvider).toBe("function");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("exports refreshOAuthToken (generic refresh)", () => {
|
|
138
|
+
if (!piAiOAuth) return;
|
|
139
|
+
expect(typeof piAiOAuth.refreshOAuthToken).toBe("function");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("exports getOAuthApiKey (get API key from credentials)", () => {
|
|
143
|
+
if (!piAiOAuth) return;
|
|
144
|
+
expect(typeof piAiOAuth.getOAuthApiKey).toBe("function");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|