portable-agent-layer 0.96.0 → 0.96.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.96.0",
3
+ "version": "0.96.1",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "scripts": {
39
39
  "type-check": "tsc --noEmit",
40
- "test": "bun test --randomize --isolate",
40
+ "test": "bun .agents/scripts/test.ts",
41
41
  "test:mutate": "stryker run",
42
42
  "test:mutate:diff": "bun .agents/scripts/mutate-diff.ts",
43
43
  "format": "biome format",
@@ -198,8 +198,8 @@ async function finishShared(docs: number): Promise<Done> {
198
198
  */
199
199
  async function refreshControlRoom(): Promise<Done | null> {
200
200
  const { isRepoMode } = await import("../hooks/handlers/update-check");
201
- const { buildPage } = await import("../tools/control-room/static");
202
- const rebuilt = isRepoMode() ? buildPage() : null;
201
+ const { refreshPage } = await import("../tools/control-room/static");
202
+ const rebuilt = refreshPage(isRepoMode());
203
203
  if (rebuilt === false)
204
204
  return {
205
205
  detail: ["page not rebuilt"],
@@ -5,35 +5,110 @@
5
5
  */
6
6
 
7
7
  import { spawnSync } from "node:child_process";
8
- import { existsSync } from "node:fs";
8
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
9
9
  import { resolve } from "node:path";
10
10
  import { isInside } from "../../hooks/lib/paths";
11
11
 
12
- const UI_DIST = resolve(import.meta.dir, "ui", "dist");
12
+ const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..");
13
+ const UI_ROOT = resolve(import.meta.dir, "ui");
14
+ const UI_DIST = resolve(UI_ROOT, "dist");
15
+ const UI_INDEX = resolve(UI_DIST, "index.html");
16
+ export const ABANDONED_LOCK_MS = 120_000;
13
17
 
14
18
  export const BUILD_COMMAND = "bun run build:ui";
15
19
 
20
+ /** Where the page is built from and to, and how; tests point it at a sandbox. */
21
+ export interface PageBuild {
22
+ sources: string;
23
+ lockfile: string;
24
+ dist: string;
25
+ lock: string;
26
+ build: () => boolean;
27
+ }
28
+
29
+ const THE_PAGE: PageBuild = {
30
+ sources: resolve(REPO_ROOT, "src"),
31
+ lockfile: resolve(REPO_ROOT, "bun.lock"),
32
+ dist: UI_DIST,
33
+ lock: resolve(UI_ROOT, "dist.lock"),
34
+ build: runViteBuild,
35
+ };
36
+
16
37
  export function isBuilt(): boolean {
17
- return existsSync(resolve(UI_DIST, "index.html"));
38
+ return existsSync(UI_INDEX);
18
39
  }
19
40
 
20
- /**
21
- * A clone has vite; a published install has the prepacked dist and never needs
22
- * it. Either way the page is there by the time the server answers.
23
- */
24
- export function buildPage(): boolean {
25
- const repoRoot = resolve(import.meta.dir, "..", "..", "..");
41
+ function newestSourceChange(page: PageBuild): number {
42
+ const sources = readdirSync(page.sources, { recursive: true, withFileTypes: true })
43
+ .filter((entry) => entry.isFile() && !isInside(page.dist, entry.parentPath))
44
+ .map((entry) => resolve(entry.parentPath, entry.name));
45
+ if (existsSync(page.lockfile)) sources.push(page.lockfile);
46
+ return Math.max(...sources.map((file) => statSync(file).mtimeMs));
47
+ }
48
+
49
+ export function isUpToDate(page: PageBuild = THE_PAGE): boolean {
50
+ const index = resolve(page.dist, "index.html");
51
+ return existsSync(index) && statSync(index).mtimeMs >= newestSourceChange(page);
52
+ }
53
+
54
+ function clearAbandonedLock(lock: string): void {
55
+ try {
56
+ if (Date.now() - statSync(lock).mtimeMs > ABANDONED_LOCK_MS)
57
+ rmSync(lock, { recursive: true, force: true });
58
+ } catch {}
59
+ }
60
+
61
+ function tryTakeBuildLock(lock: string): boolean {
62
+ try {
63
+ mkdirSync(lock);
64
+ return true;
65
+ } catch (error) {
66
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ function whileHoldingBuildLock(lock: string, build: () => boolean): boolean {
72
+ while (!tryTakeBuildLock(lock)) {
73
+ clearAbandonedLock(lock);
74
+ Bun.sleepSync(50);
75
+ }
76
+ try {
77
+ return build();
78
+ } finally {
79
+ rmSync(lock, { recursive: true, force: true });
80
+ }
81
+ }
82
+
83
+ /** A clone has vite; a published install has the prepacked dist and never needs it. */
84
+ function runViteBuild(): boolean {
26
85
  // node_modules/.bin/vite is a shell shim that Windows cannot spawn directly;
27
86
  // the package's own JS entry runs the same build under any runtime.
28
- const vite = resolve(repoRoot, "node_modules", "vite", "bin", "vite.js");
87
+ const vite = resolve(REPO_ROOT, "node_modules", "vite", "bin", "vite.js");
29
88
  if (!existsSync(vite)) return false;
30
89
  const built = spawnSync(process.execPath, [vite, "build"], {
31
- cwd: resolve(import.meta.dir, "ui"),
90
+ cwd: UI_ROOT,
32
91
  encoding: "utf-8",
33
92
  });
34
93
  return built.status === 0 && isBuilt();
35
94
  }
36
95
 
96
+ /**
97
+ * vite empties dist before writing it, so one build at a time, and none once
98
+ * another process has already brought it up to date.
99
+ */
100
+ export function buildPage(page: PageBuild = THE_PAGE): boolean {
101
+ return whileHoldingBuildLock(page.lock, () => isUpToDate(page) || page.build());
102
+ }
103
+
104
+ /** A checkout's dist is whatever was built last, so an install rebuilds it if the sources moved on. */
105
+ export function refreshPage(
106
+ inCheckout: boolean,
107
+ page: PageBuild = THE_PAGE
108
+ ): boolean | null {
109
+ return inCheckout && !isUpToDate(page) ? buildPage(page) : null;
110
+ }
111
+
37
112
  function unbuiltPage(): Response {
38
113
  return new Response(
39
114
  `<!doctype html><meta charset="utf-8"><title>PAL control room</title>` +