diff --git a/docs/developers/harness-tool-context.mdx b/docs/developers/harness-tool-context.mdx new file mode 100644 index 0000000000..8107d29a91 --- /dev/null +++ b/docs/developers/harness-tool-context.mdx @@ -0,0 +1,90 @@ +--- +title: "Running the CLI from an agent harness" +description: "How an agent harness declares what it supports through HF_TOOL_CONTEXT, and what the CLI reports back." +--- + +An agent harness (Claude Code, Codex, your own) runs `hyperframes` commands on the agent's behalf. +`HF_TOOL_CONTEXT` lets the harness tell one invocation what it supports, so the CLI can do more for +it: report the images it produced, or wait its turn instead of racing another command in the same +project. Without the variable, every command behaves exactly as it does in a terminal. + +```bash +export HF_TOOL_CONTEXT='{"version":1,"capabilities":{ + "events": {"transport": {"type": "ndjson-file", "path": "/tmp/call-42/events.ndjson"}}, + "project-lock": {"waitMs": 90000} +}}' +npx hyperframes snapshot --at 1,3 +``` + +The harness sets the variable for one tool call, reads what the call produced, and removes its files. + +## The declaration + +| Field | Value | +| --- | --- | +| `version` | `1`. Any other version, or a value that is not JSON, is treated as no declaration. | +| `capabilities` | An object keyed by capability name. Each value configures that capability. | + +The CLI ignores capability names it does not know, and a known capability whose value is invalid, so +a harness can declare capabilities newer than the CLI it runs. The variable carries data only: no +capability runs code the harness names. + +## `events`: what a command produced + +```json +{ "transport": { "type": "ndjson-file", "path": "/absolute/events.ndjson" } } +``` + +The CLI appends one JSON object per line to `path`. It never creates the parent directory, and a +write that fails prints a warning without failing the command. Read the file after the command +exits; several commands in one shell call append to the same file. + +Each line is an event: + +```json +{ + "version": 1, + "type": "artifact.created", + "command": "snapshot", + "artifact": { "role": "contact-sheet", "path": "/project/snapshots/contact-sheet.jpg", "mediaType": "image/jpeg" } +} +``` + +| Command | `role` | What it is | +| --- | --- | --- | +| `snapshot` | `frame` | Each captured frame (PNG) | +| `snapshot` | `contact-sheet` | Each page of the grid of frames made for review (JPEG) | +| `check` | `frame` | Each frame the browser check captured | +| `check` | `finding` | Each crop around a finding | + +Paths are absolute and stay where the command saved them. What to do with an artifact is the +harness's decision: attach the contact sheet to the tool result, or ignore it. +Ignore event types and roles you do not know; new ones are added without a version change. + +## `project-lock`: one browser command at a time + +```json +{ "waitMs": 90000 } +``` + +`check` and `snapshot` take a lock on the project before they start a browser, and release it when +they finish. A second command waits up to `waitMs` milliseconds for the lock, then fails, naming the +command and process that hold it. A lock whose process has exited is taken over. A +`project.lock.break` left by a killed process is not: the timeout names that file and its process +id, and says to delete it to continue. The lock is the file `.hyperframes/project.lock`. + +Choose `waitMs` below your tool call's own timeout, so a wait ends in the CLI's message rather than +the harness killing the call. + +## Hooks that run code + +Running your own command before or after a CLI command is not a `HF_TOOL_CONTEXT` capability and +never will be: anything that sets an environment variable could then run code. Such hooks, when they +exist, come only from configuration the person controls. + +## Conformance + +`packages/cli/src/utils/toolContext.test.ts` and `projectLock.test.ts` pin the parsing, event and +lock behavior above. A harness is correct when it sets the variable for each call it wants the +behavior on, reads `events` only after the call exits, and works the same when the CLI ignores a +capability. diff --git a/docs/docs.json b/docs/docs.json index 85b47012ff..72a6dc8b80 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -960,6 +960,7 @@ "developers/cli", "packages/cli", "developers/catalog-search", + "developers/harness-tool-context", "packages/lint" ] }, diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 902e269292..9f29272905 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -1,5 +1,7 @@ import { runCommand } from "citty"; -import { readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; const trackCheckReport = vi.fn(); @@ -530,6 +532,34 @@ it("threads --no-proxy into the browser check options", async () => { expect(runPipeline).toHaveBeenCalledWith(PROJECT, expect.objectContaining({ autoProxy: false })); }); +it("waits for the project lock declared in HF_TOOL_CONTEXT before running the pipeline", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-check-lock-")); + const lock = join(dir, ".hyperframes", "project.lock"); + mkdirSync(join(dir, ".hyperframes")); + writeFileSync(lock, JSON.stringify({ pid: process.pid, command: "snapshot", startedAt: 1 })); + vi.stubEnv( + "HF_TOOL_CONTEXT", + JSON.stringify({ version: 1, capabilities: { "project-lock": { waitMs: 0 } } }), + ); + const { report } = await runScenario(fakeDriver()); + const runPipeline = vi.fn(async () => report); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => ({ ...PROJECT, dir }), + runPipeline, + withMeta: (value) => value, + }); + try { + await runCommand(command, { rawArgs: ["--json"] }); + expect(runPipeline).not.toHaveBeenCalled(); + expect(consumeCommandResult().exitCode).toBe(1); + expect(String(log.mock.calls[0]?.[0])).toContain("hyperframes snapshot"); + } finally { + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); + } +}); + it("rejects malformed caption-zone specs instead of silently disabling the gate", async () => { const { report } = await runScenario(fakeDriver()); const runPipeline = vi.fn(async () => report); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 01a8204254..7257fd762a 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -1,4 +1,7 @@ +import { resolve } from "node:path"; import { formatFindingTimes } from "../utils/checkFindings.js"; +import { withProjectLock } from "../utils/projectLock.js"; +import { reportArtifacts } from "../utils/toolContext.js"; import { defineCommand, parseArgs } from "citty"; import type { ArgsDef } from "citty"; import type { Example } from "./_examples.js"; @@ -37,7 +40,13 @@ export interface CheckCommandDependencies { const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = { resolveProject, - runPipeline: runCheckPipeline, + async runPipeline(project, options) { + const report = await runCheckPipeline(project, options); + const images = (files: readonly string[]) => files.map((file) => resolve(project.dir, file)); + reportArtifacts("check", "frame", images(report.snapshots.files)); + reportArtifacts("check", "finding", images(report.snapshots.findingFiles)); + return report; + }, withMeta, }; @@ -158,7 +167,9 @@ export function createCheckCommand( if (!asJson) { console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`); } - const report = await dependencies.runPipeline(project, options); + const report = await withProjectLock(project.dir, "check", () => + dependencies.runPipeline(project, options), + ); if (asJson) { console.log(JSON.stringify(dependencies.withMeta(report), null, 2)); } else { diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 9d50c8ae0a..ad6d75093b 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -1,3 +1,5 @@ +import { reportArtifacts } from "../utils/toolContext.js"; +import { withProjectLock } from "../utils/projectLock.js"; import { parseSnapshotRange, sampleSnapshotRange, @@ -1010,152 +1012,167 @@ export default defineCommand({ const snapshotDir = args.output ? resolve(String(args.output)) : join(project.dir, "snapshots"); - const paths = await captureSnapshots(project.dir, { - frames, - range, - composition, - timeout, - at: atTimestamps, - outputDir: snapshotDir, - angle: camera, - includeEnd: args.end !== false, - zoom: zoomTarget, - zoomScale, - autoProxy: args.proxy as boolean | undefined, - browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined), - against, - }); - - if (paths.length === 0) { - console.log( - `\n${c.error("✗")} Could not determine composition duration — no frames captured`, + await withProjectLock(project.dir, "snapshot", async () => { + const paths = await captureSnapshots(project.dir, { + frames, + range, + composition, + timeout, + at: atTimestamps, + outputDir: snapshotDir, + angle: camera, + includeEnd: args.end !== false, + zoom: zoomTarget, + zoomScale, + autoProxy: args.proxy as boolean | undefined, + browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined), + against, + }); + reportArtifacts( + "snapshot", + "frame", + paths.map((path) => resolve(project.dir, path)), ); - failCommand(); - } - console.log( - `\n${c.success("◇")} ${paths.length} snapshots saved to ${args.output ? snapshotDir : "snapshots/"}`, - ); - for (const p of paths) { - console.log(` ${p}`); - } - if (against) { - console.log( - ` ${c.dim("ref-*.png + pair-*.jpg")} beside each frame (reference frame, render | reference sheet)`, - ); - } - - // Generate contact sheet for quick AI review - try { - const { createSnapshotContactSheet } = await import("../capture/contactSheet.js"); - const sheets = await createSnapshotContactSheet( - snapshotDir, - join(snapshotDir, "contact-sheet.jpg"), - ); - for (const sheet of sheets) { - console.log(` ${resolve(sheet)} (grid view for AI review)`); + if (paths.length === 0) { + console.log( + `\n${c.error("✗")} Could not determine composition duration — no frames captured`, + ); + failCommand(); } - } catch (err) { - console.warn( - ` ${c.warn("⚠")} Contact sheet skipped: ${normalizeErrorMessage(err)}. Individual snapshot PNGs are still available.`, - ); - } - // Gemini vision descriptions. Runs by default — see describeArg - // resolution above. `null` means the user explicitly opted out with - // `--describe false`; missing GEMINI_API_KEY logs a skip and continues. - if (describeArg !== null) { + console.log( + `\n${c.success("◇")} ${paths.length} snapshots saved to ${args.output ? snapshotDir : "snapshots/"}`, + ); + for (const p of paths) { + console.log(` ${p}`); + } + if (against) { + console.log( + ` ${c.dim("ref-*.png + pair-*.jpg")} beside each frame (reference frame, render | reference sheet)`, + ); + } + + // Generate contact sheet for quick AI review try { - const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; - if (!geminiKey) { - if (args.describe !== undefined) - console.log(` ${c.dim("--describe: GEMINI_API_KEY not set, skipping")}`); - } else if (paths.length > 0) { - console.log(` ${c.dim("Describing frames with Gemini vision...")}`); - const { GoogleGenAI } = await loadOptionalPackage("@google/genai", "--describe"); - const ai = new GoogleGenAI({ apiKey: geminiKey }); - const model = process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview"; - const customQuestion = - describeArg === "true" - ? "Describe this video composition frame in 1-2 sentences. Be specific and factual: what elements are visible, what text appears, is the frame blank/black/loading, what is the composition. Flag any obvious problems." - : describeArg; - - const descriptions: string[] = [ - `# Snapshot Frame Descriptions`, - ``, - `**Question asked:** ${customQuestion}`, - ``, - `Compare each description against your storyboard spec. A "black frame" or "loading screen" for a content beat is a bug.`, - ``, - ]; - - // Scale down PNGs before sending to stay under Gemini's 4 MB inline - // limit. Full 1920×1080 PNGs are typically 3-6 MB. Use sharp if - // available; otherwise skip files over the limit. - type SharpFn = (buf: Buffer) => { - resize: (w: number) => { jpeg: () => { toBuffer: () => Promise } }; - }; - let sharpFn: SharpFn | null = null; - try { - const s = await import("sharp"); - sharpFn = (s.default ?? s) as unknown as SharpFn; - } catch { - /* sharp not installed — fall back to size check */ - } - - const results = await Promise.allSettled( - paths.map(async (p) => { - const filename = basename(p); - const filePath = join(snapshotDir, filename); - if (!existsSync(filePath)) return { filename, desc: "file not found" }; - const raw = readFileSync(filePath); - let imageData: Buffer; - let mimeType = "image/png"; - if (sharpFn) { - imageData = await sharpFn(raw).resize(960).jpeg().toBuffer(); - mimeType = "image/jpeg"; - } else { - if (raw.length > 3_800_000) - return { - filename, - desc: "file too large for Gemini — install sharp to enable auto-resize", - }; - imageData = raw; - } - const base64 = imageData.toString("base64"); - const response = await ai.models.generateContent({ - model, - contents: [ - { - role: "user", - parts: [{ inlineData: { mimeType, data: base64 } }, { text: customQuestion }], - }, - ], - config: { maxOutputTokens: 250 }, - }); - return { filename, desc: response.text?.trim() || "no description" }; - }), - ); - - for (const result of results) { - if (result.status === "fulfilled") { - descriptions.push(`## ${result.value.filename}`, `${result.value.desc}`, ``); - } else { - // Log first failure so Gemini issues are visible rather than silent - const errMsg = normalizeErrorMessage(result.reason); - descriptions.push(`## (error)`, `Gemini call failed: ${errMsg.slice(0, 120)}`, ``); - } - } - - const descPath = join(snapshotDir, "descriptions.md"); - writeFileSync(descPath, descriptions.join("\n")); - console.log(` ${c.dim("descriptions.md")} (Gemini frame analysis)`); + const { createSnapshotContactSheet } = await import("../capture/contactSheet.js"); + const sheets = await createSnapshotContactSheet( + snapshotDir, + join(snapshotDir, "contact-sheet.jpg"), + ); + reportArtifacts("snapshot", "contact-sheet", sheets); + for (const sheet of sheets) { + console.log(` ${resolve(sheet)} (grid view for AI review)`); } - } catch (descErr) { - const msg = normalizeErrorMessage(descErr); - console.log(` ${c.dim(`--describe failed: ${msg}`)}`); + } catch (err) { + console.warn( + ` ${c.warn("⚠")} Contact sheet skipped: ${normalizeErrorMessage(err)}. Individual snapshot PNGs are still available.`, + ); } - } + + // Gemini vision descriptions. Runs by default — see describeArg + // resolution above. `null` means the user explicitly opted out with + // `--describe false`; missing GEMINI_API_KEY logs a skip and continues. + if (describeArg !== null) { + try { + const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; + if (!geminiKey) { + if (args.describe !== undefined) + console.log(` ${c.dim("--describe: GEMINI_API_KEY not set, skipping")}`); + } else if (paths.length > 0) { + console.log(` ${c.dim("Describing frames with Gemini vision...")}`); + const { GoogleGenAI } = await loadOptionalPackage("@google/genai", "--describe"); + const ai = new GoogleGenAI({ apiKey: geminiKey }); + const model = process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview"; + const customQuestion = + describeArg === "true" + ? "Describe this video composition frame in 1-2 sentences. Be specific and factual: what elements are visible, what text appears, is the frame blank/black/loading, what is the composition. Flag any obvious problems." + : describeArg; + + const descriptions: string[] = [ + `# Snapshot Frame Descriptions`, + ``, + `**Question asked:** ${customQuestion}`, + ``, + `Compare each description against your storyboard spec. A "black frame" or "loading screen" for a content beat is a bug.`, + ``, + ]; + + // Scale down PNGs before sending to stay under Gemini's 4 MB inline + // limit. Full 1920×1080 PNGs are typically 3-6 MB. Use sharp if + // available; otherwise skip files over the limit. + type SharpFn = (buf: Buffer) => { + resize: (w: number) => { jpeg: () => { toBuffer: () => Promise } }; + }; + let sharpFn: SharpFn | null = null; + try { + const s = await import("sharp"); + sharpFn = (s.default ?? s) as unknown as SharpFn; + } catch { + /* sharp not installed — fall back to size check */ + } + + const results = await Promise.allSettled( + paths.map(async (p) => { + const filename = basename(p); + const filePath = join(snapshotDir, filename); + if (!existsSync(filePath)) return { filename, desc: "file not found" }; + const raw = readFileSync(filePath); + let imageData: Buffer; + let mimeType = "image/png"; + if (sharpFn) { + imageData = await sharpFn(raw).resize(960).jpeg().toBuffer(); + mimeType = "image/jpeg"; + } else { + if (raw.length > 3_800_000) + return { + filename, + desc: "file too large for Gemini — install sharp to enable auto-resize", + }; + imageData = raw; + } + const base64 = imageData.toString("base64"); + const response = await ai.models.generateContent({ + model, + contents: [ + { + role: "user", + parts: [ + { inlineData: { mimeType, data: base64 } }, + { text: customQuestion }, + ], + }, + ], + config: { maxOutputTokens: 250 }, + }); + return { filename, desc: response.text?.trim() || "no description" }; + }), + ); + + for (const result of results) { + if (result.status === "fulfilled") { + descriptions.push(`## ${result.value.filename}`, `${result.value.desc}`, ``); + } else { + // Log first failure so Gemini issues are visible rather than silent + const errMsg = normalizeErrorMessage(result.reason); + descriptions.push( + `## (error)`, + `Gemini call failed: ${errMsg.slice(0, 120)}`, + ``, + ); + } + } + + const descPath = join(snapshotDir, "descriptions.md"); + writeFileSync(descPath, descriptions.join("\n")); + console.log(` ${c.dim("descriptions.md")} (Gemini frame analysis)`); + } + } catch (descErr) { + const msg = normalizeErrorMessage(descErr); + console.log(` ${c.dim(`--describe failed: ${msg}`)}`); + } + } + }); } catch (err) { const msg = normalizeErrorMessage(err); console.error(`\n${c.error("✗")} Snapshot failed: ${msg}`); diff --git a/packages/cli/src/utils/projectLock.test.ts b/packages/cli/src/utils/projectLock.test.ts new file mode 100644 index 0000000000..a955f7c384 --- /dev/null +++ b/packages/cli/src/utils/projectLock.test.ts @@ -0,0 +1,161 @@ +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { ProjectLockTimeout, projectLockPath, withProjectLock } from "./projectLock.js"; + +const directories: string[] = []; +const project = () => { + const directory = mkdtempSync(join(tmpdir(), "hf-project-lock-")); + directories.push(directory); + return directory; +}; +afterEach(() => { + for (const directory of directories.splice(0)) + rmSync(directory, { recursive: true, force: true }); +}); +const locked = (waitMs: number) => ({ projectLock: { waitMs } }); +const hold = (dir: string, pid: number) => { + mkdirSync(dirname(projectLockPath(dir)), { recursive: true }); + writeFileSync(projectLockPath(dir), JSON.stringify({ pid, command: "check", startedAt: 1 })); +}; + +describe("withProjectLock", () => { + it("runs without a lock file when the capability is absent", async () => { + const dir = project(); + expect( + await withProjectLock(dir, "check", async () => existsSync(projectLockPath(dir)), {}), + ).toBe(false); + }); + + it("holds the lock while running and releases it afterwards, even on failure", async () => { + const dir = project(); + const seen = await withProjectLock( + dir, + "snapshot", + async () => JSON.parse(readFileSync(projectLockPath(dir), "utf8")), + locked(0), + ); + expect(seen).toMatchObject({ pid: process.pid, command: "snapshot" }); + expect(existsSync(projectLockPath(dir))).toBe(false); + await expect( + withProjectLock(dir, "check", async () => Promise.reject(new Error("boom")), locked(0)), + ).rejects.toThrow("boom"); + expect(existsSync(projectLockPath(dir))).toBe(false); + }); + + it("serializes concurrent holders in one project", async () => { + const dir = project(); + const order: string[] = []; + const task = (name: string) => + withProjectLock( + dir, + name, + async () => { + order.push(`${name}:start`); + await new Promise((done) => setTimeout(done, 50)); + order.push(`${name}:end`); + }, + locked(5000), + ); + await Promise.all([task("check"), task("snapshot")]); + expect(order).toEqual(["check:start", "check:end", "snapshot:start", "snapshot:end"]); + }); + + it("waits up to waitMs for a live holder, then refuses with who holds it", async () => { + const dir = project(); + hold(dir, process.pid); + const started = Date.now(); + await expect(withProjectLock(dir, "snapshot", async () => "ran", locked(300))).rejects.toThrow( + ProjectLockTimeout, + ); + expect(Date.now() - started).toBeGreaterThanOrEqual(300); + await expect(withProjectLock(dir, "snapshot", async () => "ran", locked(0))).rejects.toThrow( + `hyperframes check (pid ${process.pid}) is still using this project`, + ); + }); + + it("takes over a lock whose process has exited", async () => { + const dir = project(); + hold(dir, 2 ** 22 + 12345); + expect(await withProjectLock(dir, "check", async () => "ran", locked(0))).toBe("ran"); + }); + + it("serializes processes that concurrently take over a dead holder", async () => { + const dir = project(); + const events = join(dir, "events.ndjson"); + hold(dir, 2 ** 22 + 12345); + const moduleUrl = pathToFileURL(join(import.meta.dirname, "projectLock.ts")).href; + const source = ` + import { appendFileSync } from "node:fs"; + import { withProjectLock } from ${JSON.stringify(moduleUrl)}; + await withProjectLock( + ${JSON.stringify(dir)}, + "check", + async () => { + appendFileSync(${JSON.stringify(events)}, JSON.stringify({ type: "start", pid: process.pid }) + "\\n"); + await new Promise((done) => setTimeout(done, 50)); + appendFileSync(${JSON.stringify(events)}, JSON.stringify({ type: "end", pid: process.pid }) + "\\n"); + }, + { projectLock: { waitMs: 5000 } }, + ); + `; + const children = Array.from({ length: 6 }, () => + spawn(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", source], { + stdio: ["ignore", "ignore", "pipe"], + }), + ); + const outcomes = await Promise.all( + children.map( + (child) => + new Promise<{ code: number | null; error: string }>((resolve) => { + let error = ""; + child.stderr.on("data", (chunk) => (error += String(chunk))); + child.on("close", (code) => resolve({ code, error })); + }), + ), + ); + expect(outcomes).toEqual(outcomes.map(() => ({ code: 0, error: "" }))); + const records = readFileSync(events, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { type: "start" | "end"; pid: number }); + let active = 0; + for (const record of records) { + active += record.type === "start" ? 1 : -1; + expect(active).toBeLessThanOrEqual(1); + } + expect(active).toBe(0); + }); + + it("refuses a breaker left by a killed process and names the file", async () => { + const dir = project(); + hold(dir, 2 ** 22 + 12345); + const breakPath = `${projectLockPath(dir)}.break`; + const source = ` + import { linkSync, writeFileSync } from "node:fs"; + const holder = { pid: process.pid, command: "check", startedAt: 1 }; + const draft = ${JSON.stringify(`${breakPath}.killed`)}; + writeFileSync(draft, JSON.stringify(holder), { flag: "wx" }); + linkSync(draft, ${JSON.stringify(breakPath)}); + process.kill(process.pid, "SIGKILL"); + `; + const child = spawn(process.execPath, ["--input-type=module", "--eval", source], { + stdio: "ignore", + }); + const outcome = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve) => { + child.on("close", (code, signal) => resolve({ code, signal })); + }, + ); + expect(outcome.signal === "SIGKILL" || outcome.code !== 0).toBe(true); + expect(existsSync(breakPath)).toBe(true); + await expect(withProjectLock(dir, "check", async () => "ran", locked(0))).rejects.toThrow( + `hyperframes left ${breakPath} (pid ${child.pid}); delete it to continue`, + ); + expect(existsSync(breakPath)).toBe(true); + expect(existsSync(projectLockPath(dir))).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/projectLock.ts b/packages/cli/src/utils/projectLock.ts new file mode 100644 index 0000000000..57276627d2 --- /dev/null +++ b/packages/cli/src/utils/projectLock.ts @@ -0,0 +1,116 @@ +import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { readToolContext, type ToolContext } from "./toolContext.js"; + +export class ProjectLockTimeout extends Error {} + +interface Holder { + pid: number; + command: string; + startedAt: number; +} + +const POLL_MS = 250; + +export const projectLockPath = (projectDir: string) => + join(projectDir, ".hyperframes", "project.lock"); + +function readHolder(path: string): Holder | null { + try { + return JSON.parse(readFileSync(path, "utf8")) as Holder; + } catch { + return null; + } +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +const sameHolder = (a: Holder | null, b: Holder) => + a?.pid === b.pid && a.startedAt === b.startedAt && a.command === b.command; + +function breakStaleHolder(path: string, current: Holder, holder: Holder): boolean { + const breakPath = `${path}.break`; + const breakDraft = `${breakPath}.${holder.pid}.${holder.startedAt}`; + let acquired = false; + writeFileSync(breakDraft, JSON.stringify(holder), { flag: "wx" }); + try { + try { + linkSync(breakDraft, breakPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } + if (sameHolder(readHolder(path), current)) { + try { + unlinkSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + acquired = tryAcquire(path, holder); + } finally { + unlinkSync(breakDraft); + if (sameHolder(readHolder(breakPath), holder)) unlinkSync(breakPath); + } + return acquired; +} + +function tryAcquire(path: string, holder: Holder): boolean { + const draft = `${path}.${holder.pid}.${holder.startedAt}`; + writeFileSync(draft, JSON.stringify(holder), { flag: "wx" }); + try { + linkSync(draft, path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } finally { + unlinkSync(draft); + } + const current = readHolder(path); + if (current && !alive(current.pid)) return breakStaleHolder(path, current, holder); + return false; +} + +export async function withProjectLock( + projectDir: string, + command: string, + run: () => Promise, + context: ToolContext = readToolContext(), +): Promise { + if (!context.projectLock) return run(); + const path = projectLockPath(projectDir); + mkdirSync(dirname(path), { recursive: true }); + const holder: Holder = { pid: process.pid, command, startedAt: Date.now() }; + const deadline = holder.startedAt + context.projectLock.waitMs; + while (!tryAcquire(path, holder)) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + const current = readHolder(path); + const breakPath = `${path}.break`; + const breaker = readHolder(breakPath); + if ((!current || !alive(current.pid)) && breaker && !alive(breaker.pid)) { + throw new ProjectLockTimeout( + `hyperframes left ${breakPath} (pid ${breaker.pid}); delete it to continue`, + ); + } + throw new ProjectLockTimeout( + `hyperframes ${current?.command ?? "another command"} (pid ${current?.pid ?? "unknown"}) ` + + `is still using this project after ${Math.round(context.projectLock.waitMs / 1000)}s`, + ); + } + await sleep(Math.min(POLL_MS, remaining)); + } + try { + return await run(); + } finally { + if (sameHolder(readHolder(path), holder)) unlinkSync(path); + } +} diff --git a/packages/cli/src/utils/toolContext.test.ts b/packages/cli/src/utils/toolContext.test.ts new file mode 100644 index 0000000000..d0e2108512 --- /dev/null +++ b/packages/cli/src/utils/toolContext.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readToolContext, reportArtifacts } from "./toolContext.js"; + +const directories: string[] = []; +const temporary = () => { + const directory = mkdtempSync(join(tmpdir(), "hf-tool-context-")); + directories.push(directory); + return directory; +}; +afterEach(() => { + for (const directory of directories.splice(0)) + rmSync(directory, { recursive: true, force: true }); +}); + +const context = (capabilities: unknown, version: unknown = 1) => + JSON.stringify({ version, capabilities }); +const eventsAt = (path: string) => ({ transport: { type: "ndjson-file", path } }); + +describe("readToolContext", () => { + it("is empty without a valid version 1 declaration", () => { + for (const raw of [ + undefined, + "", + "not json", + "[]", + context({ events: eventsAt("/tmp/e") }, 2), + context(["events"]), + ]) + expect(readToolContext(raw)).toEqual({}); + }); + + it("keeps each valid capability and ignores invalid or unknown ones", () => { + const raw = context({ + events: eventsAt("/tmp/events.ndjson"), + "project-lock": { waitMs: -1 }, + "future-capability": { anything: true }, + }); + expect(readToolContext(raw)).toEqual({ events: { path: "/tmp/events.ndjson" } }); + expect(readToolContext(context({ "project-lock": { waitMs: 500 } }))).toEqual({ + projectLock: { waitMs: 500 }, + }); + expect(readToolContext(context({ events: eventsAt("relative.ndjson") }))).toEqual({}); + expect( + readToolContext(context({ events: { transport: { type: "http", path: "/x" } } })), + ).toEqual({}); + }); +}); + +describe("reportArtifacts", () => { + it("appends one artifact.created line per path, with absolute paths and media types", () => { + const path = join(temporary(), "events.ndjson"); + const events = { events: { path } }; + reportArtifacts("snapshot", "contact-sheet", ["/p/contact-sheet.jpg"], events); + reportArtifacts("check", "finding", ["/p/crop.bin", "/p/crop.png"], events); + const lines = readFileSync(path, "utf8") + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line)); + expect(lines).toEqual([ + { + version: 1, + type: "artifact.created", + command: "snapshot", + artifact: { + role: "contact-sheet", + path: resolve("/p/contact-sheet.jpg"), + mediaType: "image/jpeg", + }, + }, + { + version: 1, + type: "artifact.created", + command: "check", + artifact: { + role: "finding", + path: resolve("/p/crop.bin"), + mediaType: "application/octet-stream", + }, + }, + { + version: 1, + type: "artifact.created", + command: "check", + artifact: { role: "finding", path: resolve("/p/crop.png"), mediaType: "image/png" }, + }, + ]); + }); + + it("writes nothing without the events capability, and only warns when the file is unwritable", () => { + expect(() => reportArtifacts("snapshot", "frame", ["/p/a.png"], {})).not.toThrow(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + reportArtifacts("snapshot", "frame", ["/p/a.png"], { + events: { path: join(temporary(), "missing", "events.ndjson") }, + }); + expect(error).toHaveBeenCalledOnce(); + error.mockRestore(); + }); +}); diff --git a/packages/cli/src/utils/toolContext.ts b/packages/cli/src/utils/toolContext.ts new file mode 100644 index 0000000000..ae872773ca --- /dev/null +++ b/packages/cli/src/utils/toolContext.ts @@ -0,0 +1,84 @@ +import { appendFileSync } from "node:fs"; +import { extname, isAbsolute, resolve } from "node:path"; + +const TOOL_CONTEXT_VERSION = 1; + +export interface ToolContext { + events?: { path: string }; + projectLock?: { waitMs: number }; +} + +export type ArtifactRole = "contact-sheet" | "frame" | "finding"; + +interface ArtifactCreatedEvent { + version: typeof TOOL_CONTEXT_VERSION; + type: "artifact.created"; + command: string; + artifact: { role: ArtifactRole; path: string; mediaType: string }; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +function parseEvents(value: unknown): ToolContext["events"] { + if (!isRecord(value) || !isRecord(value.transport)) return undefined; + const { type, path } = value.transport; + if (type !== "ndjson-file" || typeof path !== "string" || !isAbsolute(path)) return undefined; + return { path }; +} + +function parseProjectLock(value: unknown): ToolContext["projectLock"] { + if (!isRecord(value)) return undefined; + const { waitMs } = value; + if (typeof waitMs !== "number" || !Number.isFinite(waitMs) || waitMs < 0) return undefined; + return { waitMs }; +} + +export function readToolContext(raw = process.env.HF_TOOL_CONTEXT): ToolContext { + if (!raw) return {}; + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return {}; + } + if (!isRecord(value) || value.version !== TOOL_CONTEXT_VERSION || !isRecord(value.capabilities)) + return {}; + const events = parseEvents(value.capabilities.events); + const projectLock = parseProjectLock(value.capabilities["project-lock"]); + return { ...(events && { events }), ...(projectLock && { projectLock }) }; +} + +const MEDIA_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", +}; + +export function reportArtifacts( + command: string, + role: ArtifactRole, + paths: readonly string[], + context = readToolContext(), +): void { + if (!context.events || paths.length === 0) return; + const lines = paths.map((path) => { + const absolute = resolve(path); + const event: ArtifactCreatedEvent = { + version: TOOL_CONTEXT_VERSION, + type: "artifact.created", + command, + artifact: { + role, + path: absolute, + mediaType: MEDIA_TYPES[extname(absolute).toLowerCase()] ?? "application/octet-stream", + }, + }; + return JSON.stringify(event) + "\n"; + }); + try { + appendFileSync(context.events.path, lines.join("")); + } catch (error) { + console.error(`HF_TOOL_CONTEXT events could not be written: ${(error as Error).message}`); + } +}