@pi-harness/pi-harness 0.1.84 → 0.1.86
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/apps/web/dist/assets/{index--BtqJS5O.css → index-CYoyyJnL.css} +1 -1
- package/apps/web/dist/assets/{index-BdtBXG0p.js → index-DQB7fCT-.js} +13 -13
- package/apps/web/dist/index.html +2 -2
- package/apps/web/package.json +1 -1
- package/apps/web/src/style.css +3 -0
- package/node_modules/@pi-harness/api-gateway/dist/index.d.ts.map +1 -1
- package/node_modules/@pi-harness/api-gateway/dist/index.js +142 -1
- package/node_modules/@pi-harness/api-gateway/dist/index.js.map +1 -1
- package/node_modules/@pi-harness/api-gateway/package.json +1 -1
- package/node_modules/@pi-harness/cli/package.json +1 -1
- package/node_modules/@pi-harness/host-webserver/package.json +1 -1
- package/node_modules/@pi-harness/web-app/package.json +1 -1
- package/package.json +6 -6
- package/packages/api-gateway/dist/index.d.ts.map +1 -1
- package/packages/api-gateway/dist/index.js +142 -1
- package/packages/api-gateway/dist/index.js.map +1 -1
- package/packages/api-gateway/package.json +1 -1
- package/packages/api-gateway/src/index.ts +136 -1
- package/packages/api-gateway/test/index.test.ts +95 -0
- package/packages/bundle-web-app/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/host-webserver/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile, type ExecFileException } from "node:child_process";
|
|
2
2
|
import { existsSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { mkdir, mkdtemp, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, mkdtemp, opendir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
@@ -60,6 +60,12 @@ const PROCESS_FAILURE_TEXT_LIMIT = 400;
|
|
|
60
60
|
const PROCESS_TIMEOUT_MS = 120_000;
|
|
61
61
|
// Mutating commands run user hooks (pre-commit, lint-staged, test suites) and may stage large trees; killing them part-way leaves index.lock and a half-finished operation behind, so they get a far more generous bound than read-only queries.
|
|
62
62
|
const GIT_MUTATION_TIMEOUT_MS = 120_000;
|
|
63
|
+
const MAX_WORKSPACE_FILES = 5_000;
|
|
64
|
+
const MAX_WORKSPACE_FILE_CANDIDATES = 20_000;
|
|
65
|
+
const MAX_WORKSPACE_DIRECTORIES = 2_000;
|
|
66
|
+
const MAX_WORKSPACE_DEPTH = 16;
|
|
67
|
+
const WORKSPACE_FILE_CACHE_MS = 1_000;
|
|
68
|
+
const IGNORED_WORKSPACE_DIRECTORIES = new Set([".git", "node_modules", ".pi", "dist", "build"]);
|
|
63
69
|
|
|
64
70
|
class PayloadTooLargeError extends Error {
|
|
65
71
|
constructor() {
|
|
@@ -768,6 +774,104 @@ function gitCommand(cwd: string, args: readonly string[], timeoutMs = GIT_TIMEOU
|
|
|
768
774
|
});
|
|
769
775
|
}
|
|
770
776
|
|
|
777
|
+
interface WorkspaceFileCatalogue {
|
|
778
|
+
readonly paths: readonly string[];
|
|
779
|
+
readonly truncated: boolean;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function ignoredWorkspaceDirectory(name: string): boolean {
|
|
783
|
+
return IGNORED_WORKSPACE_DIRECTORIES.has(process.platform === "win32" || process.platform === "darwin" ? name.toLowerCase() : name);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function existingWorkspaceFiles(root: string, candidates: readonly string[], truncated: boolean): Promise<WorkspaceFileCatalogue> {
|
|
787
|
+
const canonicalRoot = await realpath(resolve(root));
|
|
788
|
+
const paths: string[] = [];
|
|
789
|
+
for (let start = 0; start < candidates.length && paths.length < MAX_WORKSPACE_FILES; start += 64) {
|
|
790
|
+
const batch = candidates.slice(start, start + 64);
|
|
791
|
+
const existing = await Promise.all(
|
|
792
|
+
batch.map(async (path) => {
|
|
793
|
+
const target = resolve(canonicalRoot, path);
|
|
794
|
+
if (escapesRoot(relative(canonicalRoot, target))) return undefined;
|
|
795
|
+
try {
|
|
796
|
+
const canonical = await realpath(target);
|
|
797
|
+
if (escapesRoot(relative(canonicalRoot, canonical)) || !(await stat(canonical)).isFile()) return undefined;
|
|
798
|
+
return path;
|
|
799
|
+
} catch {
|
|
800
|
+
return undefined;
|
|
801
|
+
}
|
|
802
|
+
}),
|
|
803
|
+
);
|
|
804
|
+
for (const path of existing) {
|
|
805
|
+
if (path !== undefined) paths.push(path);
|
|
806
|
+
if (paths.length >= MAX_WORKSPACE_FILES) break;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return { paths: paths.sort(), truncated: truncated || paths.length >= MAX_WORKSPACE_FILES };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function gitWorkspaceFiles(root: string): Promise<WorkspaceFileCatalogue | undefined> {
|
|
813
|
+
const result = await gitCommand(root, ["ls-files", "--cached", "--others", "--exclude-standard", "--deduplicate", "-z"]);
|
|
814
|
+
if (result.code !== 0 && result.terminated !== "output-limit") {
|
|
815
|
+
if (/not a git repository/iu.test(result.stderr)) return undefined;
|
|
816
|
+
throw new Error(result.terminated ? gitTerminationMessage("ls-files", result.terminated) : result.stderr.trim() || "Unable to list workspace files");
|
|
817
|
+
}
|
|
818
|
+
const lastBoundary = result.stdout.lastIndexOf("\0");
|
|
819
|
+
const completeOutput = result.terminated === "output-limit" ? result.stdout.slice(0, lastBoundary + 1) : result.stdout;
|
|
820
|
+
const allCandidates = [...new Set(completeOutput.split("\0").filter((path) => path !== "" && !path.includes("\uFFFD")))];
|
|
821
|
+
const candidates = allCandidates.slice(0, MAX_WORKSPACE_FILE_CANDIDATES);
|
|
822
|
+
return existingWorkspaceFiles(root, candidates, result.terminated === "output-limit" || allCandidates.length > candidates.length);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function walkedWorkspaceFiles(root: string): Promise<WorkspaceFileCatalogue> {
|
|
826
|
+
const canonicalRoot = await realpath(resolve(root));
|
|
827
|
+
const pending: Array<{ readonly directory: string; readonly depth: number }> = [{ directory: canonicalRoot, depth: 0 }];
|
|
828
|
+
const paths: string[] = [];
|
|
829
|
+
let directories = 0;
|
|
830
|
+
let scanned = 0;
|
|
831
|
+
let truncated = false;
|
|
832
|
+
while (pending.length > 0 && paths.length < MAX_WORKSPACE_FILES && directories < MAX_WORKSPACE_DIRECTORIES && scanned < MAX_WORKSPACE_FILE_CANDIDATES) {
|
|
833
|
+
const current = pending.shift();
|
|
834
|
+
if (current === undefined) break;
|
|
835
|
+
directories += 1;
|
|
836
|
+
try {
|
|
837
|
+
const handle = await opendir(current.directory);
|
|
838
|
+
for await (const entry of handle) {
|
|
839
|
+
scanned += 1;
|
|
840
|
+
if (scanned > MAX_WORKSPACE_FILE_CANDIDATES) {
|
|
841
|
+
truncated = true;
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
const name = entry.name;
|
|
845
|
+
if (name.includes("\uFFFD")) {
|
|
846
|
+
truncated = true;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
const target = join(current.directory, name);
|
|
850
|
+
if (entry.isSymbolicLink()) continue;
|
|
851
|
+
if (entry.isFile()) {
|
|
852
|
+
paths.push(relative(canonicalRoot, target).split(sep).join("/"));
|
|
853
|
+
if (paths.length >= MAX_WORKSPACE_FILES) {
|
|
854
|
+
truncated = true;
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
} else if (entry.isDirectory() && !ignoredWorkspaceDirectory(name)) {
|
|
858
|
+
if (current.depth >= MAX_WORKSPACE_DEPTH) truncated = true;
|
|
859
|
+
else pending.push({ directory: target, depth: current.depth + 1 });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
} catch (error) {
|
|
863
|
+
if (current.directory === canonicalRoot) throw error;
|
|
864
|
+
truncated = true;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (pending.length > 0 || directories >= MAX_WORKSPACE_DIRECTORIES || scanned >= MAX_WORKSPACE_FILE_CANDIDATES) truncated = true;
|
|
868
|
+
return { paths: paths.sort(), truncated };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function workspaceFiles(root: string): Promise<WorkspaceFileCatalogue> {
|
|
872
|
+
return (await gitWorkspaceFiles(root)) ?? walkedWorkspaceFiles(root);
|
|
873
|
+
}
|
|
874
|
+
|
|
771
875
|
interface EveryApiCliAuthStatus {
|
|
772
876
|
configured: false;
|
|
773
877
|
source: "everyapi-cli";
|
|
@@ -880,6 +984,22 @@ export default {
|
|
|
880
984
|
};
|
|
881
985
|
const disposePluginPanels = registerPluginPanels(context, services);
|
|
882
986
|
let busy = false;
|
|
987
|
+
let workspaceFileCache: { readonly cwd: string; readonly expiresAt: number; readonly catalogue: WorkspaceFileCatalogue } | undefined;
|
|
988
|
+
let workspaceFileRequest: { readonly cwd: string; readonly promise: Promise<WorkspaceFileCatalogue> } | undefined;
|
|
989
|
+
const readWorkspaceFiles = (cwd: string): Promise<WorkspaceFileCatalogue> => {
|
|
990
|
+
if (workspaceFileCache?.cwd === cwd && workspaceFileCache.expiresAt > Date.now()) return Promise.resolve(workspaceFileCache.catalogue);
|
|
991
|
+
if (workspaceFileRequest?.cwd === cwd) return workspaceFileRequest.promise;
|
|
992
|
+
const promise = workspaceFiles(cwd)
|
|
993
|
+
.then((catalogue) => {
|
|
994
|
+
workspaceFileCache = { cwd, expiresAt: Date.now() + WORKSPACE_FILE_CACHE_MS, catalogue };
|
|
995
|
+
return catalogue;
|
|
996
|
+
})
|
|
997
|
+
.finally(() => {
|
|
998
|
+
if (workspaceFileRequest?.promise === promise) workspaceFileRequest = undefined;
|
|
999
|
+
});
|
|
1000
|
+
workspaceFileRequest = { cwd, promise };
|
|
1001
|
+
return promise;
|
|
1002
|
+
};
|
|
883
1003
|
const events: StampedSessionEvent[] = [];
|
|
884
1004
|
const eventClients = new Set<ServerResponse>();
|
|
885
1005
|
const initialRunAt = Date.now();
|
|
@@ -1626,6 +1746,20 @@ export default {
|
|
|
1626
1746
|
sendJson(response, 200, { items, ...(status.truncated ? { truncated: true } : {}) });
|
|
1627
1747
|
},
|
|
1628
1748
|
});
|
|
1749
|
+
const disposeWorkspaceFiles = services.webServer.register({
|
|
1750
|
+
path: "/api/workspace/files",
|
|
1751
|
+
async handler(_request, response) {
|
|
1752
|
+
try {
|
|
1753
|
+
const catalogue = await readWorkspaceFiles(activeCwd(services));
|
|
1754
|
+
sendJson(response, 200, {
|
|
1755
|
+
items: catalogue.paths.map((path) => ({ path, status: "", label: "workspace" })),
|
|
1756
|
+
truncated: catalogue.truncated,
|
|
1757
|
+
});
|
|
1758
|
+
} catch (error) {
|
|
1759
|
+
sendJson(response, 500, { error: errorText(error) });
|
|
1760
|
+
}
|
|
1761
|
+
},
|
|
1762
|
+
});
|
|
1629
1763
|
const disposeFileDiff = services.webServer.register({
|
|
1630
1764
|
path: "/api/files/diff",
|
|
1631
1765
|
async handler(request, response) {
|
|
@@ -2339,6 +2473,7 @@ export default {
|
|
|
2339
2473
|
disposeWorkspaces();
|
|
2340
2474
|
disposePickDirectory();
|
|
2341
2475
|
disposeFiles();
|
|
2476
|
+
disposeWorkspaceFiles();
|
|
2342
2477
|
disposeFileDiff();
|
|
2343
2478
|
disposeFileCommit();
|
|
2344
2479
|
disposeFileRevert();
|
|
@@ -2380,6 +2380,101 @@ describe("API gateway plugin", () => {
|
|
|
2380
2380
|
expect(invalid.status).toBe(400);
|
|
2381
2381
|
});
|
|
2382
2382
|
|
|
2383
|
+
test("lists tracked and untracked workspace files independently from Git changes", async () => {
|
|
2384
|
+
const context = new Context();
|
|
2385
|
+
contexts.push(context);
|
|
2386
|
+
const workspace = await mkdtemp(join(tmpdir(), "pi-harness-workspace-files-"));
|
|
2387
|
+
temporaryDirectories.push(workspace);
|
|
2388
|
+
await execFile("git", ["init", "-q"], { cwd: workspace });
|
|
2389
|
+
await mkdir(join(workspace, "src"));
|
|
2390
|
+
await mkdir(join(workspace, "node_modules"));
|
|
2391
|
+
await writeFile(join(workspace, ".gitignore"), "ignored.log\nnode_modules/\n");
|
|
2392
|
+
await writeFile(join(workspace, "README.md"), "tracked\n");
|
|
2393
|
+
await writeFile(join(workspace, "src", "app.ts"), "export {};\n");
|
|
2394
|
+
await writeFile(join(workspace, "draft.md"), "untracked\n");
|
|
2395
|
+
await writeFile(join(workspace, "ignored.log"), "ignored\n");
|
|
2396
|
+
await writeFile(join(workspace, "node_modules", "dependency.js"), "ignored\n");
|
|
2397
|
+
await execFile("git", ["add", ".gitignore", "README.md", "src/app.ts"], { cwd: workspace });
|
|
2398
|
+
await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
|
|
2399
|
+
const session = { sessionId: "workspace-files-session", sessionFile: undefined, messages: [], isStreaming: false, subscribe: () => () => {} };
|
|
2400
|
+
context.provide("piRuntime", { session, prompt: () => Promise.resolve(), abort: () => Promise.resolve(), dispose: () => Promise.resolve() } as never);
|
|
2401
|
+
context.provide("piModels", { model: { provider: "test", id: "model" }, runtime: { getModels: () => [], getModel: () => undefined } } as never);
|
|
2402
|
+
context.provide("piHarnessLaunch", { cwd: workspace, agentDir: "/tmp/agent", args: [], requestExit() {} });
|
|
2403
|
+
await context.plugin(apiPlugin);
|
|
2404
|
+
|
|
2405
|
+
const response = await fetch(context.webServer.url + "/api/workspace/files");
|
|
2406
|
+
|
|
2407
|
+
expect(response.status).toBe(200);
|
|
2408
|
+
await expect(response.json()).resolves.toEqual({
|
|
2409
|
+
items: [
|
|
2410
|
+
{ path: ".gitignore", status: "", label: "workspace" },
|
|
2411
|
+
{ path: "README.md", status: "", label: "workspace" },
|
|
2412
|
+
{ path: "draft.md", status: "", label: "workspace" },
|
|
2413
|
+
{ path: "src/app.ts", status: "", label: "workspace" },
|
|
2414
|
+
],
|
|
2415
|
+
truncated: false,
|
|
2416
|
+
});
|
|
2417
|
+
});
|
|
2418
|
+
|
|
2419
|
+
test("lists bounded files in a non-Git workspace without following symlinks or generated directories", async () => {
|
|
2420
|
+
const context = new Context();
|
|
2421
|
+
contexts.push(context);
|
|
2422
|
+
const workspace = await mkdtemp(join(tmpdir(), "pi-harness-plain-workspace-files-"));
|
|
2423
|
+
const outside = await mkdtemp(join(tmpdir(), "pi-harness-plain-workspace-outside-"));
|
|
2424
|
+
temporaryDirectories.push(workspace, outside);
|
|
2425
|
+
await mkdir(join(workspace, "src"));
|
|
2426
|
+
await mkdir(join(workspace, "node_modules"));
|
|
2427
|
+
await writeFile(join(workspace, "README.md"), "root\n");
|
|
2428
|
+
await writeFile(join(workspace, "src", "app.ts"), "export {};\n");
|
|
2429
|
+
await writeFile(join(workspace, "node_modules", "dependency.js"), "ignored\n");
|
|
2430
|
+
await writeFile(join(outside, "secret.txt"), "outside\n");
|
|
2431
|
+
await symlink(join(outside, "secret.txt"), join(workspace, "linked-secret.txt"));
|
|
2432
|
+
await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
|
|
2433
|
+
const session = { sessionId: "plain-workspace-files-session", sessionFile: undefined, messages: [], isStreaming: false, subscribe: () => () => {} };
|
|
2434
|
+
context.provide("piRuntime", { session, prompt: () => Promise.resolve(), abort: () => Promise.resolve(), dispose: () => Promise.resolve() } as never);
|
|
2435
|
+
context.provide("piModels", { model: { provider: "test", id: "model" }, runtime: { getModels: () => [], getModel: () => undefined } } as never);
|
|
2436
|
+
context.provide("piHarnessLaunch", { cwd: workspace, agentDir: "/tmp/agent", args: [], requestExit() {} });
|
|
2437
|
+
await context.plugin(apiPlugin);
|
|
2438
|
+
|
|
2439
|
+
const response = await fetch(context.webServer.url + "/api/workspace/files");
|
|
2440
|
+
|
|
2441
|
+
expect(response.status).toBe(200);
|
|
2442
|
+
await expect(response.json()).resolves.toEqual({
|
|
2443
|
+
items: [
|
|
2444
|
+
{ path: "README.md", status: "", label: "workspace" },
|
|
2445
|
+
{ path: "src/app.ts", status: "", label: "workspace" },
|
|
2446
|
+
],
|
|
2447
|
+
truncated: false,
|
|
2448
|
+
});
|
|
2449
|
+
});
|
|
2450
|
+
|
|
2451
|
+
test("shares a short workspace catalogue cache across rapid console refreshes", async () => {
|
|
2452
|
+
const context = new Context();
|
|
2453
|
+
contexts.push(context);
|
|
2454
|
+
const workspace = await mkdtemp(join(tmpdir(), "pi-harness-workspace-file-cache-"));
|
|
2455
|
+
temporaryDirectories.push(workspace);
|
|
2456
|
+
const shimDirectory = join(workspace, "bin");
|
|
2457
|
+
const calls = join(workspace, "git-calls.txt");
|
|
2458
|
+
await mkdir(shimDirectory);
|
|
2459
|
+
await writeFile(join(workspace, "README.md"), "cached\n");
|
|
2460
|
+
await writeFile(join(shimDirectory, "git"), `#!/bin/sh\nprintf x >> ${JSON.stringify(calls)}\nprintf 'README.md\\0'\n`, { mode: 0o755 });
|
|
2461
|
+
await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
|
|
2462
|
+
const session = { sessionId: "workspace-file-cache-session", sessionFile: undefined, messages: [], isStreaming: false, subscribe: () => () => {} };
|
|
2463
|
+
context.provide("piRuntime", { session, prompt: () => Promise.resolve(), abort: () => Promise.resolve(), dispose: () => Promise.resolve() } as never);
|
|
2464
|
+
context.provide("piModels", { model: { provider: "test", id: "model" }, runtime: { getModels: () => [], getModel: () => undefined } } as never);
|
|
2465
|
+
context.provide("piHarnessLaunch", { cwd: workspace, agentDir: "/tmp/agent", args: [], requestExit() {} });
|
|
2466
|
+
await context.plugin(apiPlugin);
|
|
2467
|
+
const originalPath = process.env.PATH ?? "";
|
|
2468
|
+
process.env.PATH = `${shimDirectory}:${originalPath}`;
|
|
2469
|
+
try {
|
|
2470
|
+
expect((await fetch(context.webServer.url + "/api/workspace/files")).status).toBe(200);
|
|
2471
|
+
expect((await fetch(context.webServer.url + "/api/workspace/files")).status).toBe(200);
|
|
2472
|
+
await expect(readFile(calls, "utf8")).resolves.toBe("x");
|
|
2473
|
+
} finally {
|
|
2474
|
+
process.env.PATH = originalPath;
|
|
2475
|
+
}
|
|
2476
|
+
});
|
|
2477
|
+
|
|
2383
2478
|
test("lists the reviewed plugin marketplace and supports bounded filters", async () => {
|
|
2384
2479
|
const context = new Context();
|
|
2385
2480
|
contexts.push(context);
|