mirror of https://github.com/garrytan/gstack.git
Merge c48972c0c8 into a3259400a3
This commit is contained in:
commit
24d4cb426d
|
|
@ -263,7 +263,13 @@ jobs:
|
||||||
report:
|
report:
|
||||||
runs-on: ubicloud-standard-8
|
runs-on: ubicloud-standard-8
|
||||||
needs: evals
|
needs: evals
|
||||||
if: always() && github.event_name == 'pull_request'
|
# Skip entirely for fork PRs: GITHUB_TOKEN is read-only on forks even with
|
||||||
|
# pull-requests/issues: write declared, so addComment always returns 401.
|
||||||
|
# Fork contributors still see all eval results in the job logs and artifacts.
|
||||||
|
if: >
|
||||||
|
always() &&
|
||||||
|
github.event_name == 'pull_request' &&
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,27 @@ import { evolve } from "./evolve";
|
||||||
import { generateDesignToCodePrompt } from "./design-to-code";
|
import { generateDesignToCodePrompt } from "./design-to-code";
|
||||||
import { serve } from "./serve";
|
import { serve } from "./serve";
|
||||||
import { gallery } from "./gallery";
|
import { gallery } from "./gallery";
|
||||||
|
import { spawn as nodeSpawn } from "child_process";
|
||||||
import {
|
import {
|
||||||
daemonStatus as daemonStatusClient,
|
daemonStatus,
|
||||||
|
shutdownDaemon,
|
||||||
ensureDaemon,
|
ensureDaemon,
|
||||||
publishBoard,
|
publishBoard,
|
||||||
shutdownDaemon,
|
|
||||||
} from "./daemon-client";
|
} from "./daemon-client";
|
||||||
import { spawn as nodeSpawn } from "child_process";
|
|
||||||
|
/**
|
||||||
|
* Parse + validate `--api-timeout <ms>`. Throws on bad input so the caller can
|
||||||
|
* surface a clear error rather than passing NaN/0/negative to setTimeout (which
|
||||||
|
* would fire the AbortController immediately and instant-abort the request).
|
||||||
|
*/
|
||||||
|
export function parseApiTimeoutMs(raw: string | undefined): number | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const parsed = parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`--api-timeout must be a positive integer (ms), got: ${raw}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function parseArgs(argv: string[]): {
|
function parseArgs(argv: string[]): {
|
||||||
command: string;
|
command: string;
|
||||||
|
|
@ -130,6 +144,17 @@ async function main(): Promise<void> {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-request timeout for OpenAI image-generation calls. Distinct from the
|
||||||
|
// existing `--timeout` flag, which controls the `compare --serve` / `serve`
|
||||||
|
// HTTP listener. See issue #1519.
|
||||||
|
let apiTimeoutMs: number | undefined;
|
||||||
|
try {
|
||||||
|
apiTimeoutMs = parseApiTimeoutMs(flags["api-timeout"] as string | undefined);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case "generate":
|
case "generate":
|
||||||
await generate({
|
await generate({
|
||||||
|
|
@ -140,6 +165,7 @@ async function main(): Promise<void> {
|
||||||
retry: flags.retry ? parseInt(flags.retry as string) : 0,
|
retry: flags.retry ? parseInt(flags.retry as string) : 0,
|
||||||
size: flags.size as string,
|
size: flags.size as string,
|
||||||
quality: flags.quality as string,
|
quality: flags.quality as string,
|
||||||
|
apiTimeoutMs,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -202,6 +228,7 @@ async function main(): Promise<void> {
|
||||||
size: flags.size as string,
|
size: flags.size as string,
|
||||||
quality: flags.quality as string,
|
quality: flags.quality as string,
|
||||||
viewports: flags.viewports as string,
|
viewports: flags.viewports as string,
|
||||||
|
apiTimeoutMs,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -210,6 +237,7 @@ async function main(): Promise<void> {
|
||||||
session: flags.session as string,
|
session: flags.session as string,
|
||||||
feedback: flags.feedback as string,
|
feedback: flags.feedback as string,
|
||||||
output: (flags.output as string) || "/tmp/gstack-iterate.png",
|
output: (flags.output as string) || "/tmp/gstack-iterate.png",
|
||||||
|
apiTimeoutMs,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -262,6 +290,7 @@ async function main(): Promise<void> {
|
||||||
screenshot: flags.screenshot as string,
|
screenshot: flags.screenshot as string,
|
||||||
brief: flags.brief as string,
|
brief: flags.brief as string,
|
||||||
output: (flags.output as string) || "/tmp/gstack-evolved.png",
|
output: (flags.output as string) || "/tmp/gstack-evolved.png",
|
||||||
|
apiTimeoutMs,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -290,7 +319,7 @@ async function main(): Promise<void> {
|
||||||
// Sub-commands: `$D daemon status` and `$D daemon stop [--force]`.
|
// Sub-commands: `$D daemon status` and `$D daemon stop [--force]`.
|
||||||
const sub = positionals[0] || "status";
|
const sub = positionals[0] || "status";
|
||||||
if (sub === "status") {
|
if (sub === "status") {
|
||||||
const s = await daemonStatusClient();
|
const s = await daemonStatus();
|
||||||
if (!s.running) {
|
if (!s.running) {
|
||||||
console.log(JSON.stringify({ running: false }, null, 2));
|
console.log(JSON.stringify({ running: false }, null, 2));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
@ -408,14 +437,16 @@ async function resolveImagePaths(input: string): Promise<string[]> {
|
||||||
// the production install to a single executable; daemon-client.ts spawns
|
// the production install to a single executable; daemon-client.ts spawns
|
||||||
// `<this binary> --daemon-mode` (or `bun run cli.ts --daemon-mode` in dev)
|
// `<this binary> --daemon-mode` (or `bun run cli.ts --daemon-mode` in dev)
|
||||||
// rather than relying on a separate daemon.ts file at a known path.
|
// rather than relying on a separate daemon.ts file at a known path.
|
||||||
if (process.argv.includes("--daemon-mode")) {
|
if (import.meta.main) {
|
||||||
const { start } = await import("./daemon");
|
if (process.argv.includes("--daemon-mode")) {
|
||||||
start();
|
const { start } = await import("./daemon");
|
||||||
// start() binds Bun.serve and registers signal handlers; this branch
|
start();
|
||||||
// never falls through to main(). Process stays alive on the bound port.
|
// start() binds Bun.serve and registers signal handlers; this branch
|
||||||
} else {
|
// never falls through to main(). Process stays alive on the bound port.
|
||||||
main().catch((err) => {
|
} else {
|
||||||
console.error(err.message || err);
|
main().catch((err) => {
|
||||||
process.exit(1);
|
console.error(err.message || err);
|
||||||
});
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,17 @@ export const COMMANDS = new Map<string, {
|
||||||
["generate", {
|
["generate", {
|
||||||
description: "Generate a UI mockup from a design brief",
|
description: "Generate a UI mockup from a design brief",
|
||||||
usage: "generate --brief \"...\" --output /path.png",
|
usage: "generate --brief \"...\" --output /path.png",
|
||||||
flags: ["--brief", "--brief-file", "--output", "--check", "--retry", "--size", "--quality"],
|
flags: ["--brief", "--brief-file", "--output", "--check", "--retry", "--size", "--quality", "--api-timeout"],
|
||||||
}],
|
}],
|
||||||
["variants", {
|
["variants", {
|
||||||
description: "Generate N design variants from a brief",
|
description: "Generate N design variants from a brief",
|
||||||
usage: "variants --brief \"...\" --count 3 --output-dir /path/",
|
usage: "variants --brief \"...\" --count 3 --output-dir /path/",
|
||||||
flags: ["--brief", "--brief-file", "--count", "--output-dir", "--size", "--quality", "--viewports"],
|
flags: ["--brief", "--brief-file", "--count", "--output-dir", "--size", "--quality", "--viewports", "--api-timeout"],
|
||||||
}],
|
}],
|
||||||
["iterate", {
|
["iterate", {
|
||||||
description: "Iterate on an existing mockup with feedback",
|
description: "Iterate on an existing mockup with feedback",
|
||||||
usage: "iterate --session /path/session.json --feedback \"...\" --output /path.png",
|
usage: "iterate --session /path/session.json --feedback \"...\" --output /path.png",
|
||||||
flags: ["--session", "--feedback", "--output"],
|
flags: ["--session", "--feedback", "--output", "--api-timeout"],
|
||||||
}],
|
}],
|
||||||
["check", {
|
["check", {
|
||||||
description: "Vision-based quality check on a mockup",
|
description: "Vision-based quality check on a mockup",
|
||||||
|
|
@ -47,7 +47,7 @@ export const COMMANDS = new Map<string, {
|
||||||
["evolve", {
|
["evolve", {
|
||||||
description: "Generate improved mockup from existing screenshot",
|
description: "Generate improved mockup from existing screenshot",
|
||||||
usage: "evolve --screenshot current.png --brief \"make it calmer\" --output /path.png",
|
usage: "evolve --screenshot current.png --brief \"make it calmer\" --output /path.png",
|
||||||
flags: ["--screenshot", "--brief", "--output"],
|
flags: ["--screenshot", "--brief", "--output", "--api-timeout"],
|
||||||
}],
|
}],
|
||||||
["verify", {
|
["verify", {
|
||||||
description: "Compare live site screenshot against approved mockup",
|
description: "Compare live site screenshot against approved mockup",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
/**
|
||||||
|
* Shared constants for the design binary.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default per-request timeout for OpenAI Responses API calls that drive the
|
||||||
|
* `image_generation` tool. The previous 120_000 ceiling tipped over at default
|
||||||
|
* settings (`gpt-4o`, 1536x1024, quality:high) on slower account tiers — see
|
||||||
|
* issue #1519. Override per-invocation via `--api-timeout <ms>`.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_IMAGE_GEN_TIMEOUT_MS = 300_000;
|
||||||
|
|
@ -8,11 +8,13 @@
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { requireApiKey } from "./auth";
|
import { requireApiKey } from "./auth";
|
||||||
|
import { DEFAULT_IMAGE_GEN_TIMEOUT_MS } from "./constants";
|
||||||
|
|
||||||
export interface EvolveOptions {
|
export interface EvolveOptions {
|
||||||
screenshot: string; // Path to current site screenshot
|
screenshot: string; // Path to current site screenshot
|
||||||
brief: string; // What to change ("make it calmer", "fix the hierarchy")
|
brief: string; // What to change ("make it calmer", "fix the hierarchy")
|
||||||
output: string; // Output path for evolved mockup
|
output: string; // Output path for evolved mockup
|
||||||
|
apiTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,7 +54,7 @@ export async function evolve(options: EvolveOptions): Promise<void> {
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 240_000);
|
const timeout = setTimeout(() => controller.abort(), options.apiTimeoutMs ?? DEFAULT_IMAGE_GEN_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://api.openai.com/v1/responses", {
|
const response = await fetch("https://api.openai.com/v1/responses", {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { requireApiKey } from "./auth";
|
||||||
import { parseBrief } from "./brief";
|
import { parseBrief } from "./brief";
|
||||||
import { createSession, sessionPath } from "./session";
|
import { createSession, sessionPath } from "./session";
|
||||||
import { checkMockup } from "./check";
|
import { checkMockup } from "./check";
|
||||||
|
import { DEFAULT_IMAGE_GEN_TIMEOUT_MS } from "./constants";
|
||||||
|
|
||||||
export interface GenerateOptions {
|
export interface GenerateOptions {
|
||||||
brief?: string;
|
brief?: string;
|
||||||
|
|
@ -17,6 +18,7 @@ export interface GenerateOptions {
|
||||||
retry?: number;
|
retry?: number;
|
||||||
size?: string;
|
size?: string;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
|
apiTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateResult {
|
export interface GenerateResult {
|
||||||
|
|
@ -35,9 +37,10 @@ async function callImageGeneration(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
size: string,
|
size: string,
|
||||||
quality: string,
|
quality: string,
|
||||||
|
timeoutMs: number,
|
||||||
): Promise<{ responseId: string; imageData: string }> {
|
): Promise<{ responseId: string; imageData: string }> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 240_000);
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://api.openai.com/v1/responses", {
|
const response = await fetch("https://api.openai.com/v1/responses", {
|
||||||
|
|
@ -106,6 +109,7 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
|
||||||
const size = options.size || "1536x1024";
|
const size = options.size || "1536x1024";
|
||||||
const quality = options.quality || "high";
|
const quality = options.quality || "high";
|
||||||
const maxRetries = options.retry ?? 0;
|
const maxRetries = options.retry ?? 0;
|
||||||
|
const apiTimeoutMs = options.apiTimeoutMs ?? DEFAULT_IMAGE_GEN_TIMEOUT_MS;
|
||||||
|
|
||||||
let lastResult: GenerateResult | null = null;
|
let lastResult: GenerateResult | null = null;
|
||||||
|
|
||||||
|
|
@ -116,7 +120,7 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
|
||||||
|
|
||||||
// Generate the image
|
// Generate the image
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const { responseId, imageData } = await callImageGeneration(apiKey, prompt, size, quality);
|
const { responseId, imageData } = await callImageGeneration(apiKey, prompt, size, quality, apiTimeoutMs);
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
|
||||||
// Write to disk
|
// Write to disk
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,13 @@ import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { requireApiKey } from "./auth";
|
import { requireApiKey } from "./auth";
|
||||||
import { readSession, updateSession } from "./session";
|
import { readSession, updateSession } from "./session";
|
||||||
|
import { DEFAULT_IMAGE_GEN_TIMEOUT_MS } from "./constants";
|
||||||
|
|
||||||
export interface IterateOptions {
|
export interface IterateOptions {
|
||||||
session: string; // Path to session JSON file
|
session: string; // Path to session JSON file
|
||||||
feedback: string; // User feedback text
|
feedback: string; // User feedback text
|
||||||
output: string; // Output path for new PNG
|
output: string; // Output path for new PNG
|
||||||
|
apiTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -29,13 +31,17 @@ export async function iterate(options: IterateOptions): Promise<void> {
|
||||||
console.error(` Feedback: "${options.feedback}"`);
|
console.error(` Feedback: "${options.feedback}"`);
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const apiTimeoutMs = options.apiTimeoutMs ?? DEFAULT_IMAGE_GEN_TIMEOUT_MS;
|
||||||
|
// Single deadline shared across threading + fallback so cumulative wait is
|
||||||
|
// bounded by apiTimeoutMs rather than 2×apiTimeoutMs.
|
||||||
|
const deadline = startTime + apiTimeoutMs;
|
||||||
|
|
||||||
// Try multi-turn with previous_response_id first
|
// Try multi-turn with previous_response_id first
|
||||||
let success = false;
|
let success = false;
|
||||||
let responseId = "";
|
let responseId = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await callWithThreading(apiKey, session.lastResponseId, options.feedback);
|
const result = await callWithThreading(apiKey, session.lastResponseId, options.feedback, apiTimeoutMs);
|
||||||
responseId = result.responseId;
|
responseId = result.responseId;
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
||||||
|
|
@ -45,13 +51,19 @@ export async function iterate(options: IterateOptions): Promise<void> {
|
||||||
console.error(` Threading failed: ${err.message}`);
|
console.error(` Threading failed: ${err.message}`);
|
||||||
console.error(" Falling back to re-generation with accumulated feedback...");
|
console.error(" Falling back to re-generation with accumulated feedback...");
|
||||||
|
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
const secs = (apiTimeoutMs / 1000).toFixed(apiTimeoutMs % 1000 === 0 ? 0 : 1);
|
||||||
|
throw new Error(`Timeout (${secs}s)`);
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: re-generate with original brief + all feedback
|
// Fallback: re-generate with original brief + all feedback
|
||||||
const accumulatedPrompt = buildAccumulatedPrompt(
|
const accumulatedPrompt = buildAccumulatedPrompt(
|
||||||
session.originalBrief,
|
session.originalBrief,
|
||||||
[...session.feedbackHistory, options.feedback]
|
[...session.feedbackHistory, options.feedback]
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await callFresh(apiKey, accumulatedPrompt);
|
const result = await callFresh(apiKey, accumulatedPrompt, remaining);
|
||||||
responseId = result.responseId;
|
responseId = result.responseId;
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
||||||
|
|
@ -80,9 +92,10 @@ async function callWithThreading(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
previousResponseId: string,
|
previousResponseId: string,
|
||||||
feedback: string,
|
feedback: string,
|
||||||
|
timeoutMs: number,
|
||||||
): Promise<{ responseId: string; imageData: string }> {
|
): Promise<{ responseId: string; imageData: string }> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 240_000);
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://api.openai.com/v1/responses", {
|
const response = await fetch("https://api.openai.com/v1/responses", {
|
||||||
|
|
@ -128,9 +141,10 @@ async function callWithThreading(
|
||||||
async function callFresh(
|
async function callFresh(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
|
timeoutMs: number,
|
||||||
): Promise<{ responseId: string; imageData: string }> {
|
): Promise<{ responseId: string; imageData: string }> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 240_000);
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://api.openai.com/v1/responses", {
|
const response = await fetch("https://api.openai.com/v1/responses", {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { requireApiKey } from "./auth";
|
import { requireApiKey } from "./auth";
|
||||||
import { parseBrief } from "./brief";
|
import { parseBrief } from "./brief";
|
||||||
|
import { DEFAULT_IMAGE_GEN_TIMEOUT_MS } from "./constants";
|
||||||
|
|
||||||
export interface VariantsOptions {
|
export interface VariantsOptions {
|
||||||
brief?: string;
|
brief?: string;
|
||||||
|
|
@ -17,6 +18,7 @@ export interface VariantsOptions {
|
||||||
size?: string;
|
size?: string;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
viewports?: string; // "desktop,tablet,mobile" — generates at multiple sizes
|
viewports?: string; // "desktop,tablet,mobile" — generates at multiple sizes
|
||||||
|
apiTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STYLE_VARIATIONS = [
|
const STYLE_VARIATIONS = [
|
||||||
|
|
@ -42,6 +44,7 @@ export async function generateVariant(
|
||||||
size: string,
|
size: string,
|
||||||
quality: string,
|
quality: string,
|
||||||
fetchFn: typeof globalThis.fetch = globalThis.fetch,
|
fetchFn: typeof globalThis.fetch = globalThis.fetch,
|
||||||
|
timeoutMs: number = DEFAULT_IMAGE_GEN_TIMEOUT_MS,
|
||||||
): Promise<{ path: string; success: boolean; error?: string }> {
|
): Promise<{ path: string; success: boolean; error?: string }> {
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
const MAX_RETRY_AFTER_MS = 60_000; // cap honored Retry-After to bound stalls
|
const MAX_RETRY_AFTER_MS = 60_000; // cap honored Retry-After to bound stalls
|
||||||
|
|
@ -58,7 +61,7 @@ export async function generateVariant(
|
||||||
skipLeadingDelay = false;
|
skipLeadingDelay = false;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 240_000);
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchFn("https://api.openai.com/v1/responses", {
|
const response = await fetchFn("https://api.openai.com/v1/responses", {
|
||||||
|
|
@ -125,7 +128,8 @@ export async function generateVariant(
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
if (err.name === "AbortError") {
|
if (err.name === "AbortError") {
|
||||||
return { path: outputPath, success: false, error: "Timeout (120s)" };
|
const secs = (timeoutMs / 1000).toFixed(timeoutMs % 1000 === 0 ? 0 : 1);
|
||||||
|
return { path: outputPath, success: false, error: `Timeout (${secs}s)` };
|
||||||
}
|
}
|
||||||
lastError = err.message;
|
lastError = err.message;
|
||||||
}
|
}
|
||||||
|
|
@ -144,12 +148,13 @@ export async function variants(options: VariantsOptions): Promise<void> {
|
||||||
: parseBrief(options.brief!, false);
|
: parseBrief(options.brief!, false);
|
||||||
|
|
||||||
const quality = options.quality || "high";
|
const quality = options.quality || "high";
|
||||||
|
const apiTimeoutMs = options.apiTimeoutMs ?? DEFAULT_IMAGE_GEN_TIMEOUT_MS;
|
||||||
|
|
||||||
fs.mkdirSync(options.outputDir, { recursive: true });
|
fs.mkdirSync(options.outputDir, { recursive: true });
|
||||||
|
|
||||||
// If viewports specified, generate responsive variants instead of style variants
|
// If viewports specified, generate responsive variants instead of style variants
|
||||||
if (options.viewports) {
|
if (options.viewports) {
|
||||||
await generateResponsiveVariants(apiKey, baseBrief, options.outputDir, options.viewports, quality);
|
await generateResponsiveVariants(apiKey, baseBrief, options.outputDir, options.viewports, quality, apiTimeoutMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +181,7 @@ export async function variants(options: VariantsOptions): Promise<void> {
|
||||||
new Promise(resolve => setTimeout(resolve, delay))
|
new Promise(resolve => setTimeout(resolve, delay))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.error(` Starting variant ${String.fromCharCode(65 + i)}...`);
|
console.error(` Starting variant ${String.fromCharCode(65 + i)}...`);
|
||||||
return generateVariant(apiKey, prompt, outputPath, size, quality);
|
return generateVariant(apiKey, prompt, outputPath, size, quality, undefined, apiTimeoutMs);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -225,6 +230,7 @@ async function generateResponsiveVariants(
|
||||||
outputDir: string,
|
outputDir: string,
|
||||||
viewports: string,
|
viewports: string,
|
||||||
quality: string,
|
quality: string,
|
||||||
|
timeoutMs: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const viewportList = viewports.split(",").map(v => v.trim().toLowerCase());
|
const viewportList = viewports.split(",").map(v => v.trim().toLowerCase());
|
||||||
const configs = viewportList.map(v => VIEWPORT_CONFIGS[v]).filter(Boolean);
|
const configs = viewportList.map(v => VIEWPORT_CONFIGS[v]).filter(Boolean);
|
||||||
|
|
@ -250,7 +256,7 @@ async function generateResponsiveVariants(
|
||||||
setTimeout(resolve, delay)
|
setTimeout(resolve, delay)
|
||||||
).then(() => {
|
).then(() => {
|
||||||
console.error(` Starting ${config.desc}...`);
|
console.error(` Starting ${config.desc}...`);
|
||||||
return generateVariant(apiKey, prompt, outputPath, config.size, quality);
|
return generateVariant(apiKey, prompt, outputPath, config.size, quality, undefined, timeoutMs);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
/**
|
||||||
|
* Regression coverage for issue #1519 — image-generation calls were timing
|
||||||
|
* out at a hardcoded 120s with no CLI override. The fix raised the default
|
||||||
|
* and exposed a per-invocation override via `--api-timeout`.
|
||||||
|
*
|
||||||
|
* These tests pin the contract that the constant exports at the new value
|
||||||
|
* and that the `timeoutMs` param is honored by the AbortController path.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import { generateVariant } from "../src/variants";
|
||||||
|
import { DEFAULT_IMAGE_GEN_TIMEOUT_MS } from "../src/constants";
|
||||||
|
import { parseApiTimeoutMs } from "../src/cli";
|
||||||
|
|
||||||
|
describe("DEFAULT_IMAGE_GEN_TIMEOUT_MS", () => {
|
||||||
|
test("default is 300_000ms (5min) — see issue #1519", () => {
|
||||||
|
expect(DEFAULT_IMAGE_GEN_TIMEOUT_MS).toBe(300_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseApiTimeoutMs", () => {
|
||||||
|
test("undefined input returns undefined (use default)", () => {
|
||||||
|
expect(parseApiTimeoutMs(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valid positive integer returns the parsed number", () => {
|
||||||
|
expect(parseApiTimeoutMs("300000")).toBe(300000);
|
||||||
|
expect(parseApiTimeoutMs("1")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-numeric input throws (would otherwise be NaN -> instant abort)", () => {
|
||||||
|
expect(() => parseApiTimeoutMs("abc")).toThrow(/positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero throws (would otherwise fire setTimeout immediately)", () => {
|
||||||
|
expect(() => parseApiTimeoutMs("0")).toThrow(/positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("negative throws (would otherwise fire setTimeout immediately)", () => {
|
||||||
|
expect(() => parseApiTimeoutMs("-1")).toThrow(/positive integer/);
|
||||||
|
expect(() => parseApiTimeoutMs("-300000")).toThrow(/positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trailing garbage is ignored (parseInt semantics; matches other flags)", () => {
|
||||||
|
expect(parseApiTimeoutMs("300000foo")).toBe(300000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateVariant timeoutMs override", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let outputPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "api-timeout-"));
|
||||||
|
outputPath = path.join(tmpDir, "variant.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aborts after explicit timeoutMs when fetch never resolves", async () => {
|
||||||
|
// Stub fetch that waits for the signal to abort, then throws AbortError.
|
||||||
|
const fetchFn = (async (_input: any, init?: any) => {
|
||||||
|
const signal = init?.signal as AbortSignal | undefined;
|
||||||
|
await new Promise<void>((_resolve, reject) => {
|
||||||
|
if (!signal) return;
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
const err: any = new Error("aborted");
|
||||||
|
err.name = "AbortError";
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new Response("never reached");
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
const result = await generateVariant(
|
||||||
|
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn, 200,
|
||||||
|
);
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("Timeout (0.2s)");
|
||||||
|
// Was aborted by the 200ms timer, not by exponential-backoff retry chain
|
||||||
|
expect(elapsed).toBeLessThan(2_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats whole-second timeouts without decimals", async () => {
|
||||||
|
const fetchFn = (async (_input: any, init?: any) => {
|
||||||
|
const signal = init?.signal as AbortSignal | undefined;
|
||||||
|
await new Promise<void>((_resolve, reject) => {
|
||||||
|
if (!signal) return;
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
const err: any = new Error("aborted");
|
||||||
|
err.name = "AbortError";
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new Response("never reached");
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const result = await generateVariant(
|
||||||
|
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn, 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("Timeout (1s)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default timeoutMs is the shared constant when omitted", async () => {
|
||||||
|
// 1x1 transparent PNG, base64
|
||||||
|
const TINY_PNG_BASE64 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
|
||||||
|
const fetchFn = (async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
output: [{ type: "image_generation_call", result: TINY_PNG_BASE64 }],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
)) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
// Should succeed using the default timeout — fetch resolves instantly here,
|
||||||
|
// so the timeoutMs value only matters for the AbortController setup not firing.
|
||||||
|
const result = await generateVariant(
|
||||||
|
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,7 +21,10 @@ import { generateCompareHtml } from '../src/compare';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import type { TabSession } from '../../browse/src/tab-session';
|
||||||
|
|
||||||
let bm: BrowserManager;
|
let bm: BrowserManager;
|
||||||
|
let session: TabSession;
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
let server: ReturnType<typeof Bun.serve>;
|
let server: ReturnType<typeof Bun.serve>;
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
|
|
@ -119,6 +122,7 @@ beforeAll(async () => {
|
||||||
|
|
||||||
bm = new BrowserManager();
|
bm = new BrowserManager();
|
||||||
await bm.launch();
|
await bm.launch();
|
||||||
|
session = bm.getActiveSession();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
@ -137,32 +141,32 @@ describe('Submit: browser click → feedback.json on disk', () => {
|
||||||
serverState = 'serving';
|
serverState = 'serving';
|
||||||
|
|
||||||
// Navigate to the board (board JS uses relative URLs + location.protocol detect)
|
// Navigate to the board (board JS uses relative URLs + location.protocol detect)
|
||||||
await handleWriteCommand('goto', [baseUrl], bm);
|
await handleWriteCommand('goto', [baseUrl], session, bm);
|
||||||
|
|
||||||
// Verify the board detects HTTP mode (so postFeedback will actually fetch
|
// Verify the board detects HTTP mode (so postFeedback will actually fetch
|
||||||
// instead of falling into the file:// DOM-only path)
|
// instead of falling into the file:// DOM-only path)
|
||||||
const httpDetected = await handleReadCommand('js', [
|
const httpDetected = await handleReadCommand('js', [
|
||||||
"location.protocol === 'http:' || location.protocol === 'https:'"
|
"location.protocol === 'http:' || location.protocol === 'https:'"
|
||||||
], bm);
|
], session, bm);
|
||||||
expect(httpDetected).toBe('true');
|
expect(httpDetected).toBe('true');
|
||||||
|
|
||||||
// User picks variant A, rates it 5 stars
|
// User picks variant A, rates it 5 stars
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.querySelectorAll("input[name=\\"preferred\\"]")[0].click()'
|
'document.querySelectorAll("input[name=\\"preferred\\"]")[0].click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.querySelectorAll(".stars")[0].querySelectorAll(".star")[4].click()'
|
'document.querySelectorAll(".stars")[0].querySelectorAll(".star")[4].click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
|
|
||||||
// User adds overall feedback
|
// User adds overall feedback
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.getElementById("overall-feedback").value = "Ship variant A"'
|
'document.getElementById("overall-feedback").value = "Ship variant A"'
|
||||||
], bm);
|
], session, bm);
|
||||||
|
|
||||||
// User clicks Submit
|
// User clicks Submit
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.getElementById("submit-btn").click()'
|
'document.getElementById("submit-btn").click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
|
|
||||||
// Wait a beat for the async POST to complete
|
// Wait a beat for the async POST to complete
|
||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
@ -186,19 +190,19 @@ describe('Submit: browser click → feedback.json on disk', () => {
|
||||||
// After submit, the page should be read-only
|
// After submit, the page should be read-only
|
||||||
const submitBtnExists = await handleReadCommand('js', [
|
const submitBtnExists = await handleReadCommand('js', [
|
||||||
'document.getElementById("submit-btn").style.display'
|
'document.getElementById("submit-btn").style.display'
|
||||||
], bm);
|
], session, bm);
|
||||||
// submit button is hidden after post-submit lifecycle
|
// submit button is hidden after post-submit lifecycle
|
||||||
expect(submitBtnExists).toBe('none');
|
expect(submitBtnExists).toBe('none');
|
||||||
|
|
||||||
const successVisible = await handleReadCommand('js', [
|
const successVisible = await handleReadCommand('js', [
|
||||||
'document.getElementById("success-msg").style.display'
|
'document.getElementById("success-msg").style.display'
|
||||||
], bm);
|
], session, bm);
|
||||||
expect(successVisible).toBe('block');
|
expect(successVisible).toBe('block');
|
||||||
|
|
||||||
// Success message should mention /design-shotgun
|
// Success message should mention /design-shotgun
|
||||||
const successText = await handleReadCommand('js', [
|
const successText = await handleReadCommand('js', [
|
||||||
'document.getElementById("success-msg").textContent'
|
'document.getElementById("success-msg").textContent'
|
||||||
], bm);
|
], session, bm);
|
||||||
expect(successText).toContain('design-shotgun');
|
expect(successText).toContain('design-shotgun');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -211,17 +215,17 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => {
|
||||||
serverState = 'serving';
|
serverState = 'serving';
|
||||||
|
|
||||||
// Fresh page
|
// Fresh page
|
||||||
await handleWriteCommand('goto', [baseUrl], bm);
|
await handleWriteCommand('goto', [baseUrl], session, bm);
|
||||||
|
|
||||||
// User clicks "Totally different" chiclet
|
// User clicks "Totally different" chiclet
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
|
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
|
|
||||||
// User clicks Regenerate
|
// User clicks Regenerate
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.getElementById("regen-btn").click()'
|
'document.getElementById("regen-btn").click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
|
|
||||||
// Wait for async POST
|
// Wait for async POST
|
||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
@ -244,12 +248,12 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => {
|
||||||
if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath);
|
if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath);
|
||||||
serverState = 'serving';
|
serverState = 'serving';
|
||||||
|
|
||||||
await handleWriteCommand('goto', [baseUrl], bm);
|
await handleWriteCommand('goto', [baseUrl], session, bm);
|
||||||
|
|
||||||
// Click "More like this" on variant B (index 1)
|
// Click "More like this" on variant B (index 1)
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.querySelectorAll(".more-like-this")[1].click()'
|
'document.querySelectorAll(".more-like-this")[1].click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
|
|
@ -263,21 +267,21 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => {
|
||||||
|
|
||||||
test('board shows spinner after regenerate (user stays on same tab)', async () => {
|
test('board shows spinner after regenerate (user stays on same tab)', async () => {
|
||||||
serverState = 'serving';
|
serverState = 'serving';
|
||||||
await handleWriteCommand('goto', [baseUrl], bm);
|
await handleWriteCommand('goto', [baseUrl], session, bm);
|
||||||
|
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
|
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.getElementById("regen-btn").click()'
|
'document.getElementById("regen-btn").click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
// Board should show "Generating new designs..." text
|
// Board should show "Generating new designs..." text
|
||||||
const bodyText = await handleReadCommand('js', [
|
const bodyText = await handleReadCommand('js', [
|
||||||
'document.body.textContent'
|
'document.body.textContent'
|
||||||
], bm);
|
], session, bm);
|
||||||
expect(bodyText).toContain('Generating new designs');
|
expect(bodyText).toContain('Generating new designs');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -291,15 +295,15 @@ describe('Full regeneration round-trip: regen → reload → submit', () => {
|
||||||
if (fs.existsSync(feedbackPath)) fs.unlinkSync(feedbackPath);
|
if (fs.existsSync(feedbackPath)) fs.unlinkSync(feedbackPath);
|
||||||
serverState = 'serving';
|
serverState = 'serving';
|
||||||
|
|
||||||
await handleWriteCommand('goto', [baseUrl], bm);
|
await handleWriteCommand('goto', [baseUrl], session, bm);
|
||||||
|
|
||||||
// Step 1: User clicks Regenerate
|
// Step 1: User clicks Regenerate
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.querySelector(".regen-chiclet[data-action=\\"match\\"]").click()'
|
'document.querySelector(".regen-chiclet[data-action=\\"match\\"]").click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.getElementById("regen-btn").click()'
|
'document.getElementById("regen-btn").click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
|
|
@ -329,21 +333,21 @@ describe('Full regeneration round-trip: regen → reload → submit', () => {
|
||||||
expect(serverState).toBe('serving');
|
expect(serverState).toBe('serving');
|
||||||
|
|
||||||
// Step 4: Board auto-refreshes (simulated by navigating again)
|
// Step 4: Board auto-refreshes (simulated by navigating again)
|
||||||
await handleWriteCommand('goto', [baseUrl], bm);
|
await handleWriteCommand('goto', [baseUrl], session, bm);
|
||||||
|
|
||||||
// Verify the board is fresh (no prior picks)
|
// Verify the board is fresh (no prior picks)
|
||||||
const status = await handleReadCommand('js', [
|
const status = await handleReadCommand('js', [
|
||||||
'document.getElementById("status").textContent'
|
'document.getElementById("status").textContent'
|
||||||
], bm);
|
], session, bm);
|
||||||
expect(status).toBe('');
|
expect(status).toBe('');
|
||||||
|
|
||||||
// Step 5: User picks variant C on round 2 and submits
|
// Step 5: User picks variant C on round 2 and submits
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.querySelectorAll("input[name=\\"preferred\\"]")[2].click()'
|
'document.querySelectorAll("input[name=\\"preferred\\"]")[2].click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
await handleReadCommand('js', [
|
await handleReadCommand('js', [
|
||||||
'document.getElementById("submit-btn").click()'
|
'document.getElementById("submit-btn").click()'
|
||||||
], bm);
|
], session, bm);
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -941,21 +941,3 @@ When significant gaps are found, suggest running `/document-generate` to fill th
|
||||||
|
|
||||||
> **STOP.** Before auditing each doc file and applying updates, polishing CHANGELOG voice, checking cross-doc consistency, cleaning up TODOS, the VERSION bump, and committing (Steps 2-9, after the coverage map in Step 1.5), Read `~/.claude/skills/gstack/document-release/sections/release-body.md` and execute it
|
> **STOP.** Before auditing each doc file and applying updates, polishing CHANGELOG voice, checking cross-doc consistency, cleaning up TODOS, the VERSION bump, and committing (Steps 2-9, after the coverage map in Step 1.5), Read `~/.claude/skills/gstack/document-release/sections/release-body.md` and execute it
|
||||||
> in full. Do not work from memory — that section is the source of truth for this step.
|
> in full. Do not work from memory — that section is the source of truth for this step.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Important Rules
|
|
||||||
|
|
||||||
- **Read before editing.** Always read the full content of a file before modifying it.
|
|
||||||
- **Never clobber CHANGELOG.** Polish wording only. Never delete, replace, or regenerate entries.
|
|
||||||
- **Never bump VERSION silently.** Always ask. Even if already bumped, check whether it covers the full scope of changes.
|
|
||||||
- **Be explicit about what changed.** Every edit gets a one-line summary.
|
|
||||||
- **Generic heuristics, not project-specific.** The audit checks work on any repo.
|
|
||||||
- **Discoverability matters.** Every doc file should be reachable from README or CLAUDE.md.
|
|
||||||
- **Coverage map informs, never generates.** The Diataxis coverage map flags gaps for the PR body
|
|
||||||
and future work. It does NOT auto-generate missing documentation pages or sections. When gaps
|
|
||||||
are found, suggest `/document-generate` as the follow-up skill.
|
|
||||||
- **Diagram drift is advisory.** Flag stale architecture diagrams in the PR body but do not
|
|
||||||
auto-edit ASCII art or Mermaid blocks — they require human judgment to update correctly.
|
|
||||||
- **Voice: friendly, user-forward, not obscure.** Write like you're explaining to a smart person
|
|
||||||
who hasn't seen the code.
|
|
||||||
|
|
|
||||||
|
|
@ -140,21 +140,3 @@ When significant gaps are found, suggest running `/document-generate` to fill th
|
||||||
---
|
---
|
||||||
|
|
||||||
{{SECTION:release-body}}
|
{{SECTION:release-body}}
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Important Rules
|
|
||||||
|
|
||||||
- **Read before editing.** Always read the full content of a file before modifying it.
|
|
||||||
- **Never clobber CHANGELOG.** Polish wording only. Never delete, replace, or regenerate entries.
|
|
||||||
- **Never bump VERSION silently.** Always ask. Even if already bumped, check whether it covers the full scope of changes.
|
|
||||||
- **Be explicit about what changed.** Every edit gets a one-line summary.
|
|
||||||
- **Generic heuristics, not project-specific.** The audit checks work on any repo.
|
|
||||||
- **Discoverability matters.** Every doc file should be reachable from README or CLAUDE.md.
|
|
||||||
- **Coverage map informs, never generates.** The Diataxis coverage map flags gaps for the PR body
|
|
||||||
and future work. It does NOT auto-generate missing documentation pages or sections. When gaps
|
|
||||||
are found, suggest `/document-generate` as the follow-up skill.
|
|
||||||
- **Diagram drift is advisory.** Flag stale architecture diagrams in the PR body but do not
|
|
||||||
auto-edit ASCII art or Mermaid blocks — they require human judgment to update correctly.
|
|
||||||
- **Voice: friendly, user-forward, not obscure.** Write like you're explaining to a smart person
|
|
||||||
who hasn't seen the code.
|
|
||||||
|
|
|
||||||
|
|
@ -475,3 +475,21 @@ Substitute: STATUS = "clean" if no gaps, "issues_found" if gaps exist. SOURCE =
|
||||||
**Cleanup:** Run `rm -f "$TMPERR_DOC"` after processing (if Codex was used).
|
**Cleanup:** Run `rm -f "$TMPERR_DOC"` after processing (if Codex was used).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Rules
|
||||||
|
|
||||||
|
- **Read before editing.** Always read the full content of a file before modifying it.
|
||||||
|
- **Never clobber CHANGELOG.** Polish wording only. Never delete, replace, or regenerate entries.
|
||||||
|
- **Never bump VERSION silently.** Always ask. Even if already bumped, check whether it covers the full scope of changes.
|
||||||
|
- **Be explicit about what changed.** Every edit gets a one-line summary.
|
||||||
|
- **Generic heuristics, not project-specific.** The audit checks work on any repo.
|
||||||
|
- **Discoverability matters.** Every doc file should be reachable from README or CLAUDE.md.
|
||||||
|
- **Coverage map informs, never generates.** The Diataxis coverage map flags gaps for the PR body
|
||||||
|
and future work. It does NOT auto-generate missing documentation pages or sections. When gaps
|
||||||
|
are found, suggest `/document-generate` as the follow-up skill.
|
||||||
|
- **Diagram drift is advisory.** Flag stale architecture diagrams in the PR body but do not
|
||||||
|
auto-edit ASCII art or Mermaid blocks — they require human judgment to update correctly.
|
||||||
|
- **Voice: friendly, user-forward, not obscure.** Write like you're explaining to a smart person
|
||||||
|
who hasn't seen the code.
|
||||||
|
|
|
||||||
|
|
@ -360,3 +360,21 @@ If all coverage is complete and no diagrams drifted, output: "Coverage: all ship
|
||||||
---
|
---
|
||||||
|
|
||||||
{{CODEX_DOC_REVIEW}}
|
{{CODEX_DOC_REVIEW}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Rules
|
||||||
|
|
||||||
|
- **Read before editing.** Always read the full content of a file before modifying it.
|
||||||
|
- **Never clobber CHANGELOG.** Polish wording only. Never delete, replace, or regenerate entries.
|
||||||
|
- **Never bump VERSION silently.** Always ask. Even if already bumped, check whether it covers the full scope of changes.
|
||||||
|
- **Be explicit about what changed.** Every edit gets a one-line summary.
|
||||||
|
- **Generic heuristics, not project-specific.** The audit checks work on any repo.
|
||||||
|
- **Discoverability matters.** Every doc file should be reachable from README or CLAUDE.md.
|
||||||
|
- **Coverage map informs, never generates.** The Diataxis coverage map flags gaps for the PR body
|
||||||
|
and future work. It does NOT auto-generate missing documentation pages or sections. When gaps
|
||||||
|
are found, suggest `/document-generate` as the follow-up skill.
|
||||||
|
- **Diagram drift is advisory.** Flag stale architecture diagrams in the PR body but do not
|
||||||
|
auto-edit ASCII art or Mermaid blocks — they require human judgment to update correctly.
|
||||||
|
- **Voice: friendly, user-forward, not obscure.** Write like you're explaining to a smart person
|
||||||
|
who hasn't seen the code.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue