← back to Visual Factory
snapshot for codex review
7c502d828288a5602b26515e7cad460fc26665b4 · 2026-04-30 23:06:29 -0700 · temp
Files touched
A .env.exampleA .gitignoreA CHANGES.mdA package-lock.jsonA package.jsonA public/index.htmlA scripts/clickthrough.jsA server.jsA sql/001_init.sqlA src/llm.jsA src/pipeline.jsA src/stages/compose.jsA src/stages/critic.jsA src/stages/intake.jsA src/stages/iterate.jsA src/stages/render.jsA src/stages/vision_check.jsA viewer.js
Diff
commit 7c502d828288a5602b26515e7cad460fc26665b4
Author: temp <temp@local>
Date: Thu Apr 30 23:06:29 2026 -0700
snapshot for codex review
---
.env.example | 14 +
.gitignore | 5 +
CHANGES.md | 19 +
package-lock.json | 1047 ++++++++++++++++++++++++++++++++++++++++++++
package.json | 20 +
public/index.html | 421 ++++++++++++++++++
scripts/clickthrough.js | 87 ++++
server.js | 696 +++++++++++++++++++++++++++++
sql/001_init.sql | 47 ++
src/llm.js | 128 ++++++
src/pipeline.js | 236 ++++++++++
src/stages/compose.js | 57 +++
src/stages/critic.js | 52 +++
src/stages/intake.js | 43 ++
src/stages/iterate.js | 40 ++
src/stages/render.js | 37 ++
src/stages/vision_check.js | 57 +++
viewer.js | 12 +
18 files changed, 3018 insertions(+)
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4847ee8
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,14 @@
+PG_HOST=/tmp
+PG_PORT=5432
+PG_DATABASE=visual_factory
+PG_USER=stevestudio2
+PG_PASSWORD=
+
+ORCHESTRATOR_PORT=9892
+VIEWER_PORT=9893
+
+OLLAMA_HOST=http://127.0.0.1:11434
+COMPOSE_MODEL=qwen3:14b
+VISION_MODEL=llava:latest
+
+CLAUDE_CLI=claude
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ba4f449
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env
+*.log
+.DS_Store
+output/
diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
index 0000000..93bfa25
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,19 @@
+## Applied
+- src/llm.js:10 — changed the Claude CLI fallback from a user-specific absolute path to `claude` from `PATH` — removes a hardcoded `/Users/...` path from source.
+- server.js:107 — added the same `CLAUDE_CLI || 'claude'` default for deep health checks — keeps server and LLM adapter behavior aligned.
+- server.js:109 — added a small executable lookup helper for absolute paths, relative paths, and `PATH` commands — lets `/health/deep` validate `claude` without a hardcoded home directory.
+- server.js:204 — changed the Claude CLI health probe to use the new lookup helper — avoids checking Steve's local absolute path.
+- .env.example:14 — changed `CLAUDE_CLI` to `claude` — documents the portable default without copying a real local path.
+
+## Deferred (needs Steve)
+- P0 — server.js:223 — create/retry/delete/activate/IG POST routes lack auth/CSRF — deferred because token, Origin, and CSRF policy are outside the safe fix list.
+- P0 — src/stages/compose.js:34, src/stages/render.js:21 — LLM HTML is written and opened as `file://` with JS/network behavior unresolved — deferred because sanitization/CSP/request-blocking choices need product/security decisions.
+- P0 — server.js:259 — retry/cancellation semantics for live runs still need an owner decision, especially `force=true` behavior — deferred because cancellation/locking is outside the safe fix list.
+- P0 — src/stages/intake.js:35, src/stages/render.js:19 — generated dimensions are not clamped before rendering — deferred because bounds policy is not in the safe fix list.
+- P1 — server.js:333 — `/runs/:id/png` sends DB paths without sandbox validation — deferred because artifact path trust policy is outside the requested safe fixes.
+- P1 — server.js:405 — activation validates `png_path` but not `html_path` before copying — deferred for the same sandbox policy reason.
+- P1 — src/stages/vision_check.js:40, src/stages/critic.js:21 — failed vision can still approve at quality 75 — deferred because changing approval policy affects generated asset workflow.
+- P1 — src/pipeline.js:195 — crash logging can use stale `run.current_stage` — deferred because it is not in safe fix items 1-7.
+- P1 — server.js:413, server.js:442 — activation copy and manifest update are not locked/atomic — deferred because concurrency design is outside the safe fix list.
+- P1 — src/stages/compose.js:16, src/stages/render.js:21 — Google Fonts plus `networkidle` can stall offline runs — deferred because asset bundling/request policy is outside the safe fix list.
+- P1 — package.json:10 — `npm run run` points to a missing script — deferred because script changes are not in safe fix items 1-7.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..e313dd5
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1047 @@
+{
+ "name": "visual-factory",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "visual-factory",
+ "version": "0.1.0",
+ "dependencies": {
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "pg": "^8.12.0",
+ "playwright": "^1.45.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.12.0",
+ "pg-pool": "^3.13.0",
+ "pg-protocol": "^1.13.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
+ "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.13.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
+ "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
+ "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d742988
--- /dev/null
+++ b/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "visual-factory",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Meta-orchestrator: brief -> rendered visual artifact (HTML→PNG via Playwright; llava vision review; Claude CLI critic).",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "viewer": "node viewer.js",
+ "run": "node scripts/run.js",
+ "pm2:start": "pm2 start server.js --name visual-factory-orchestrator && pm2 start viewer.js --name visual-factory-viewer && pm2 save",
+ "playwright:install": "npx playwright install chromium"
+ },
+ "dependencies": {
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "pg": "^8.12.0",
+ "playwright": "^1.45.0"
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..c013804
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,421 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<title>The Visual Factory</title>
+<style>
+ :root {
+ --bg: #0e0e10; --panel: #16161a; --line: #2a2a30;
+ --text: #e8e8ea; --muted: #8a8a92; --accent: #d4af37;
+ }
+ html, body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; }
+ header { padding: 24px 32px; border-bottom: 1px solid var(--line); display: flex; align-items: baseline; gap: 16px; }
+ header h1 { margin: 0; font-size: 20px; letter-spacing: 0.02em; }
+ header .sub { color: var(--muted); font-size: 12px; }
+ main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
+ form { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin-bottom: 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: start; }
+ form .full { grid-column: 1 / -1; }
+ form label { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
+ input[type=text], textarea { width: 100%; box-sizing: border-box; background: #050507; border: 1px solid var(--line); color: var(--text); padding: 10px 12px; border-radius: 6px; font: inherit; }
+ textarea { resize: vertical; min-height: 60px; }
+ form .actions { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
+ form .toggle { color: var(--muted); font-size: 12px; cursor: pointer; user-select: none; }
+ form .toggle:hover { color: var(--accent); }
+ button { background: var(--accent); color: #1b1b1b; border: 0; padding: 10px 16px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer; }
+ button:disabled { opacity: 0.4; cursor: not-allowed; }
+ .activate-btn { padding: 4px 10px; font-size: 12px; }
+ .row-btn { padding: 4px 8px; font-size: 11px; background: transparent; color: var(--muted); border: 1px solid var(--line); }
+ .row-btn:hover { color: var(--text); border-color: var(--accent); }
+ .row-btn.danger:hover { color: #ff6b6b; border-color: #ff6b6b; }
+
+ .tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
+ .tab { background: var(--panel); border: 1px solid var(--line); color: var(--muted); padding: 6px 12px; border-radius: 999px; font-size: 12px; cursor: pointer; user-select: none; }
+ .tab:hover { color: var(--text); }
+ .tab.active { background: var(--accent); color: #1b1b1b; border-color: var(--accent); font-weight: 600; }
+ .tab .count { opacity: 0.65; margin-left: 6px; font-size: 10px; }
+
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; cursor: pointer; transition: border-color 0.15s; }
+ .card:hover { border-color: var(--accent); }
+ .thumb { width: 100%; aspect-ratio: 1/1; background: #050507 center/contain no-repeat; border-bottom: 1px solid var(--line); }
+ .meta { padding: 12px 14px; }
+ .meta .name { font-weight: 600; }
+ .meta .brief { color: var(--muted); font-size: 12px; margin-top: 4px; line-height: 1.4; max-height: 3.6em; overflow: hidden; }
+ .meta .row { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; gap: 8px; }
+ .meta .status { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
+ .meta .status.activated { color: #5fb878; }
+ .meta .status.unapproved, .meta .status.completed_unapproved, .meta .status.failed, .meta .status.failed_intake, .meta .status.failed_orphan { color: #ff6b6b; }
+ .meta .status.awaiting_activation, .meta .status.awaiting_activation_v2 { color: var(--accent); }
+ .meta .status.running, .meta .status.pending { color: #6ab7ff; }
+ .meta .badges { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
+ .meta .badge { font-size: 10px; padding: 2px 7px; border-radius: 4px; background: #050507; border: 1px solid var(--line); color: var(--muted); letter-spacing: 0.04em; }
+ .meta .badge.score { color: #5fb878; border-color: #2c4d36; }
+ .meta .badge.iter { color: var(--accent); border-color: #5a4912; }
+ .meta .badge.stage { color: #6ab7ff; border-color: #1f3a5a; }
+ .meta .errsnip { color: #ff6b6b; font-size: 11px; margin-top: 6px; max-height: 2.6em; overflow: hidden; line-height: 1.3; }
+ .meta .actions-row { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
+ .empty { color: var(--muted); padding: 60px 0; text-align: center; }
+ .err { color: #ff6b6b; font-size: 12px; margin-top: 8px; }
+ .health { display: flex; gap: 12px; flex-wrap: wrap; padding: 10px 14px; margin-bottom: 16px; border-radius: 6px; background: var(--panel); border: 1px solid var(--line); font-size: 12px; align-items: center; }
+ .health.bad { border-color: #ff6b6b; background: #2a1212; }
+ .health.warn { border-color: #f4b400; }
+ .health .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
+ .health .ok { background: #5fb878; }
+ .health .nok { background: #ff6b6b; }
+ .health .wn { background: #f4b400; }
+ .health .lbl { color: var(--muted); }
+ .health .qd { margin-left: auto; color: var(--muted); }
+
+ .modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
+ .modal-bg.open { display: flex; }
+ .modal { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; max-width: 1100px; width: 100%; max-height: 90vh; overflow: auto; padding: 20px 24px; }
+ .modal h2 { margin: 0 0 8px; font-size: 16px; }
+ .modal .brief-full { color: var(--muted); margin-bottom: 16px; }
+ .modal img { width: 100%; max-height: 60vh; object-fit: contain; background: #050507; border: 1px solid var(--line); border-radius: 4px; }
+ .event { display: grid; grid-template-columns: 60px 110px 50px 1fr; gap: 12px; padding: 4px 0; border-bottom: 1px dotted #222; font-size: 12px; }
+ .event:last-child { border-bottom: 0; }
+ .event .stg { color: var(--muted); } .event .nm { color: var(--accent); }
+ .event .lv { font-size: 10px; text-transform: uppercase; }
+ .event .lv.warn { color: #f4b400; } .event .lv.error { color: #ff6b6b; } .event .lv.info { color: #5fb878; }
+ .event details summary { color: var(--muted); cursor: pointer; font-size: 10px; }
+ .event pre { background: #050507; color: #d0d0d4; padding: 8px; margin: 4px 0 0; border-radius: 4px; font-size: 11px; overflow-x: auto; max-height: 200px; }
+</style>
+</head>
+<body>
+<header>
+ <h1>The Visual Factory</h1>
+ <span class="sub">brief → composed HTML → headless render → vision review · :9892</span>
+</header>
+<main>
+ <form id="newRun">
+ <div>
+ <label for="domain">Domain</label>
+ <input id="domain" type="text" placeholder="philipperomano.com" autocomplete="off" />
+ </div>
+ <div>
+ <label for="purpose">Purpose</label>
+ <input id="purpose" type="text" placeholder="Spring 2026 collection launch — Instagram feed" autocomplete="off" />
+ </div>
+ <div class="full">
+ <label for="details">Details (optional — palette, mood, copy, dimensions)</label>
+ <textarea id="details" placeholder="Editorial mood, ivory + deep green, bold serif headline `New: Mind the Gap collection`, 1080×1080…"></textarea>
+ </div>
+ <div class="full">
+ <label for="brief">Or paste a free-text brief instead (overrides above)</label>
+ <textarea id="brief" placeholder="Leave blank unless you want to skip the structured fields."></textarea>
+ </div>
+ <div class="actions">
+ <span class="toggle" id="exampleBtn">Fill example →</span>
+ <button type="submit">Build</button>
+ </div>
+ </form>
+ <div id="err" class="err"></div>
+ <div id="health"></div>
+ <div class="tabs" id="tabs"></div>
+ <div class="grid" id="grid"><div class="empty">No runs yet.</div></div>
+</main>
+
+<div class="modal-bg" id="modal-bg">
+ <div class="modal" id="modal">
+ <h2 id="m-title"></h2>
+ <div class="brief-full" id="m-brief"></div>
+ <img id="m-img" />
+ <div id="m-events" style="margin-top: 16px;"></div>
+ </div>
+</div>
+
+<script>
+// Same-origin: the orchestrator now serves this page, so paths are relative.
+const ORCH = '';
+const STAGE_NAMES = ['queued','intake','compose','render','vision_check','critic','iterate','catalog','activate'];
+let currentFilter = localStorage.getItem('vf:filter') || 'all';
+let lastRuns = [];
+
+// Bucket a run into a UI tab. Server statuses are messier than the user
+// thinks about them — collapse here so the tabs stay simple.
+function bucket(status) {
+ if (!status) return 'other';
+ if (status === 'pending' || status === 'running') return 'running';
+ if (status.startsWith('awaiting_activation')) return 'awaiting';
+ if (status === 'activated') return 'activated';
+ if (status.startsWith('failed') || status === 'completed_unapproved' || status === 'unapproved') return 'failed';
+ return 'other';
+}
+
+const TABS = [
+ { id: 'all', label: 'All' },
+ { id: 'running', label: 'Running' },
+ { id: 'awaiting', label: 'Awaiting activation' },
+ { id: 'activated', label: 'Activated' },
+ { id: 'failed', label: 'Failed' },
+];
+
+function renderTabs(runs) {
+ const counts = Object.fromEntries(TABS.map(t => [t.id, 0]));
+ counts.all = runs.length;
+ for (const r of runs) {
+ const b = bucket(r.status);
+ if (b in counts) counts[b]++;
+ }
+ document.getElementById('tabs').innerHTML = TABS.map(t =>
+ `<span class="tab ${currentFilter===t.id?'active':''}" data-filter="${t.id}">${t.label}<span class="count">${counts[t.id]||0}</span></span>`
+ ).join('');
+ document.querySelectorAll('.tab').forEach(el => {
+ el.addEventListener('click', () => {
+ currentFilter = el.dataset.filter;
+ localStorage.setItem('vf:filter', currentFilter);
+ renderTabs(lastRuns);
+ renderGrid(lastRuns);
+ });
+ });
+}
+
+function renderGrid(runs) {
+ const grid = document.getElementById('grid');
+ const filtered = currentFilter === 'all' ? runs : runs.filter(r => bucket(r.status) === currentFilter);
+ if (!filtered.length) { grid.innerHTML = `<div class="empty">No runs in “${currentFilter}”.</div>`; return; }
+ grid.innerHTML = filtered.map(r => {
+ const status = String(r.status || '');
+ const canActivate = status.startsWith('awaiting_activation');
+ const canRetry = /^(failed|completed_unapproved|unapproved)/.test(status);
+ const isRunning = status === 'running' || status === 'pending';
+ const dimText = `${r.width||'?'}×${r.height||'?'}`;
+ const domainBadge = r.domain
+ ? `<span style="color:var(--accent);font-size:10px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase">${escapeHtml(r.domain)}</span>`
+ : '';
+ const subline = r.purpose ? escapeHtml(r.purpose) : escapeHtml(r.brief || '');
+ const vMatch = status.match(/^awaiting_activation_v(\d+)$/);
+ const badges = [];
+ if (isRunning && r.current_stage != null) {
+ const sn = STAGE_NAMES[r.current_stage] || `stage ${r.current_stage}`;
+ badges.push(`<span class="badge stage">▸ ${escapeHtml(sn)}</span>`);
+ }
+ if (r.score != null) badges.push(`<span class="badge score">★ ${r.score}/100</span>`);
+ if (vMatch) {
+ const v = Number(vMatch[1]);
+ badges.push(`<span class="badge iter">v${v} · iterated ${v - 1}×</span>`);
+ }
+ const errsnip = (status.startsWith('failed') || status === 'completed_unapproved') && r.last_error
+ ? `<div class="errsnip" title="${escapeHtml(r.last_error)}">${escapeHtml(r.last_error.slice(0, 120))}${r.last_error.length > 120 ? '…' : ''}</div>`
+ : '';
+ const actions = [];
+ if (canActivate) actions.push(`<button data-id="${r.id}" class="activate-btn">Activate</button>`);
+ if (canRetry) actions.push(`<button data-id="${r.id}" class="row-btn retry-btn">Retry</button>`);
+ if (status === 'activated') actions.push(`<button data-id="${r.id}" class="row-btn ig-btn">Post to IG</button>`);
+ if (status !== 'activated' && !isRunning) actions.push(`<button data-id="${r.id}" class="row-btn danger delete-btn">Delete</button>`);
+ return `
+ <div class="card" data-id="${r.id}">
+ <div class="thumb" style="background-image:url('${ORCH}/runs/${r.id}/png?t=${Date.now()}')"></div>
+ <div class="meta">
+ ${domainBadge}
+ <div class="name">${escapeHtml(r.artifact_name || `run ${r.id}`)} <span style="color:var(--muted);font-weight:400;font-size:11px">${dimText}</span></div>
+ <div class="brief">${subline}</div>
+ ${badges.length ? `<div class="badges">${badges.join('')}</div>` : ''}
+ ${errsnip}
+ <div class="row"><span class="status ${status}">${escapeHtml(status)}</span></div>
+ ${actions.length ? `<div class="actions-row">${actions.join('')}</div>` : ''}
+ </div>
+ </div>`;
+ }).join('');
+
+ document.querySelectorAll('.activate-btn').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ if (!confirm(`Publish run ${btn.dataset.id} to ~/Pictures/visual-factory-published/ ?`)) return;
+ btn.disabled = true;
+ const r = await fetch(`${ORCH}/runs/${btn.dataset.id}/activate`, { method: 'POST' });
+ const d = await r.json();
+ if (d.error) alert('Activate failed: ' + d.error + '\n' + (d.hint||''));
+ else alert('Published → ' + d.destPng);
+ loadRuns();
+ });
+ });
+ document.querySelectorAll('.retry-btn').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ if (!confirm(`Retry run ${btn.dataset.id}? Wipes its events + artifact, re-runs the pipeline.`)) return;
+ btn.disabled = true;
+ const r = await fetch(`${ORCH}/runs/${btn.dataset.id}/retry`, { method: 'POST' });
+ const d = await r.json();
+ if (d.error) alert('Retry failed: ' + d.error);
+ loadRuns();
+ });
+ });
+ document.querySelectorAll('.delete-btn').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ if (!confirm(`Delete run ${btn.dataset.id}? Removes its events, artifact row, and output dir. Activated PNGs (in Pictures) are NOT touched.`)) return;
+ btn.disabled = true;
+ const r = await fetch(`${ORCH}/runs/${btn.dataset.id}`, { method: 'DELETE' });
+ const d = await r.json();
+ if (d.error) alert('Delete failed: ' + d.error);
+ loadRuns();
+ });
+ });
+ document.querySelectorAll('.ig-btn').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ if (!confirm(`Post run ${btn.dataset.id} to Instagram via Norma?\n\nNorma is in SIMULATION mode unless IG creds are set — this will probably just log a fake media_id. Real posts also require the image to be hosted at a public URL (loopback won't work for Meta).`)) return;
+ btn.disabled = true;
+ const r = await fetch(`${ORCH}/runs/${btn.dataset.id}/post-ig`, { method: 'POST' });
+ const d = await r.json();
+ if (d.ok) {
+ const sim = d.response?.result?.simulated;
+ alert(`IG post ${sim ? 'SIMULATED' : 'submitted'} → media_id=${d.response?.result?.media_id || '?'}\n\nCaption:\n${d.caption}`);
+ } else {
+ alert(`IG post failed: ${d.error || 'http ' + d.status}`);
+ }
+ loadRuns();
+ });
+ });
+ document.querySelectorAll('.card').forEach(c => {
+ c.addEventListener('click', () => openModal(c.dataset.id));
+ });
+}
+
+// Codex P2 #14 fix: cancel an in-flight loadRuns / openModal before issuing a
+// new one, so an older slow response can't overwrite newer state when the user
+// switches filters fast or the 2s modal poll laps the previous request.
+let _loadRunsCtrl = null;
+let _modalCtrl = null;
+
+async function loadRuns() {
+ if (_loadRunsCtrl) _loadRunsCtrl.abort();
+ const ctrl = (_loadRunsCtrl = new AbortController());
+ try {
+ const res = await fetch(ORCH + '/runs', { signal: ctrl.signal });
+ if (ctrl !== _loadRunsCtrl) return; // we were superseded mid-flight
+ const data = await res.json();
+ const errEl = document.getElementById('err');
+ if (data.error) { errEl.textContent = data.error + (data.hint ? ' — ' + data.hint : ''); return; }
+ errEl.textContent = '';
+ if (!data.runs) { document.getElementById('grid').innerHTML = '<div class="empty">No runs yet.</div>'; renderTabs([]); return; }
+ lastRuns = data.runs;
+ renderTabs(lastRuns);
+ renderGrid(lastRuns);
+ } catch (e) {
+ if (e.name === 'AbortError') return; // superseded — don't surface
+ document.getElementById('err').textContent = `orchestrator unreachable — ${e.message}`;
+ }
+}
+
+async function openModal(id, keepOpen) {
+ // Cancel any prior modal fetch so a slow `/events` response from a different
+ // run can't paint into the modal we're now showing for a different id.
+ if (_modalCtrl) _modalCtrl.abort();
+ const ctrl = (_modalCtrl = new AbortController());
+ // Capture the modal target locally; if modalRunId changes during await
+ // (user clicked another card / closed modal), bail before painting.
+ const targetId = String(id);
+ try {
+ const res = await fetch(ORCH + '/runs', { signal: ctrl.signal });
+ const d = await res.json();
+ if (ctrl !== _modalCtrl) return;
+ const r = (d.runs||[]).find(x => String(x.id) === targetId);
+ if (!r) return;
+ if (!keepOpen) modalRunId = id;
+ // After the await the user may have closed the modal; if so, don't paint.
+ if (keepOpen && String(modalRunId) !== targetId) return;
+ document.getElementById('m-title').textContent = `${r.artifact_name||'run '+id} — ${r.status}`;
+ document.getElementById('m-brief').textContent = r.brief;
+ // Only refresh the image src on open — re-setting it every 2s causes flicker.
+ if (!keepOpen) {
+ document.getElementById('m-img').src = `${ORCH}/runs/${id}/png?t=${Date.now()}`;
+ }
+ const evRes = await fetch(`${ORCH}/runs/${id}/events`, { signal: ctrl.signal });
+ if (ctrl !== _modalCtrl) return;
+ const ev = await evRes.json();
+ if (keepOpen && String(modalRunId) !== targetId) return;
+ const evEl = document.getElementById('m-events');
+ const wasAtBottom = evEl.scrollHeight - evEl.scrollTop - evEl.clientHeight < 40;
+ evEl.innerHTML = (ev.events||[]).map(e => {
+ const payload = e.payload ? `<details><summary>payload</summary><pre>${escapeHtml(JSON.stringify(e.payload, null, 2))}</pre></details>` : '';
+ return `<div class="event"><span class="stg">stage ${e.stage}</span><span class="nm">${e.stage_name}</span><span class="lv ${e.level}">${e.level}</span><span>${escapeHtml(e.message||'')}${payload}</span></div>`;
+ }).join('');
+ if (wasAtBottom) evEl.scrollTop = evEl.scrollHeight;
+ if (!keepOpen) document.getElementById('modal-bg').classList.add('open');
+ } catch (e) {
+ if (e.name !== 'AbortError') console.error('openModal failed:', e);
+ }
+}
+
+document.getElementById('modal-bg').addEventListener('click', (e) => {
+ if (e.target.id === 'modal-bg') {
+ e.target.classList.remove('open');
+ modalRunId = null;
+ }
+});
+
+document.getElementById('newRun').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const brief = document.getElementById('brief').value.trim();
+ const domain = document.getElementById('domain').value.trim();
+ const purpose = document.getElementById('purpose').value.trim();
+ const details = document.getElementById('details').value.trim();
+ if (!brief && (!domain || !purpose)) {
+ document.getElementById('err').textContent = 'Need either a brief OR domain + purpose.';
+ return;
+ }
+ document.getElementById('err').textContent = '';
+ const res = await fetch(ORCH + '/runs', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ brief, domain, purpose, details })
+ });
+ const data = await res.json();
+ if (data.error) { document.getElementById('err').textContent = data.error; return; }
+ ['brief','domain','purpose','details'].forEach(id => document.getElementById(id).value = '');
+ loadRuns();
+});
+
+document.getElementById('exampleBtn').addEventListener('click', () => {
+ document.getElementById('domain').value = 'philipperomano.com';
+ document.getElementById('purpose').value = 'Spring 2026 collection launch — Instagram 1080×1080';
+ document.getElementById('details').value = 'Editorial mood, ivory + deep coastal blue palette, bold serif headline "Coastal: Spring 2026", subhead "Inspired by the New England shoreline", CTA pill "View collection →".';
+});
+
+function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
+
+// Health banner — calls /health/deep on load + every 30s. Shows red when DB or
+// Ollama is unreachable so users don't submit doomed runs. Queue depth + cap
+// shown on the right so concurrency caps are visible.
+async function loadHealth() {
+ const el = document.getElementById('health');
+ try {
+ const r = await fetch(ORCH + '/health/deep');
+ const d = await r.json();
+ const dot = (s) => s === 'ok' || (typeof s === 'string' && s.startsWith('ok'))
+ ? '<span class="dot ok"></span>'
+ : (typeof s === 'string' && s.includes('missing'))
+ ? '<span class="dot wn"></span>'
+ : '<span class="dot nok"></span>';
+ const cls = d.ok ? '' : 'bad';
+ const q = d.queue || {};
+ el.className = 'health ' + cls;
+ el.innerHTML = `
+ <span>${dot(d.checks.db)}<span class="lbl">db</span> ${escapeHtml(String(d.checks.db))}</span>
+ <span>${dot(d.checks.ollama)}<span class="lbl">ollama</span> ${escapeHtml(String(d.checks.ollama))}</span>
+ <span>${dot(d.checks.claude_cli)}<span class="lbl">critic</span> ${escapeHtml(String(d.checks.claude_cli))}</span>
+ <span>${dot(d.checks.playwright)}<span class="lbl">playwright</span> ${escapeHtml(String(d.checks.playwright))}</span>
+ <span class="qd">queue ${q.in_flight||0}/${q.capacity||'?'} in flight · ${q.queued||0} queued</span>`;
+ } catch (e) {
+ el.className = 'health bad';
+ el.innerHTML = `<span><span class="dot nok"></span>orchestrator unreachable</span>`;
+ }
+}
+
+let pollIv = setInterval(loadRuns, 4000);
+let modalRunId = null;
+async function refreshModal() {
+ if (!modalRunId) return;
+ await openModal(modalRunId, /* keepOpen */ true);
+}
+setInterval(refreshModal, 2000);
+
+loadRuns();
+loadHealth();
+setInterval(loadHealth, 30_000);
+</script>
+</body>
+</html>
diff --git a/scripts/clickthrough.js b/scripts/clickthrough.js
new file mode 100644
index 0000000..1c3f343
--- /dev/null
+++ b/scripts/clickthrough.js
@@ -0,0 +1,87 @@
+// Live click-through of the Visual Factory viewer.
+// Opens :9893, screenshots the gallery, clicks each row's modal, screenshots that.
+
+const { chromium } = require('playwright');
+const path = require('node:path');
+const fs = require('node:fs/promises');
+
+const VIEWER = 'http://127.0.0.1:9893';
+const ORCH = 'http://127.0.0.1:9892';
+const OUT = path.join(__dirname, '..', 'output', 'clickthrough');
+
+(async () => {
+ await fs.mkdir(OUT, { recursive: true });
+ const browser = await chromium.launch({ headless: true });
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 });
+ const page = await ctx.newPage();
+ const consoleMsgs = [];
+ page.on('console', m => consoleMsgs.push(`[${m.type()}] ${m.text()}`));
+ page.on('pageerror', e => consoleMsgs.push(`[PAGEERROR] ${e.message}`));
+
+ console.log('STEP 1: gallery');
+ await page.goto(VIEWER, { waitUntil: 'networkidle', timeout: 15000 });
+ // Give the JS poll a moment to load runs from the orch
+ await page.waitForTimeout(1500);
+ await page.screenshot({ path: path.join(OUT, '01-gallery.png'), fullPage: true });
+
+ // How many cards rendered?
+ const cardCount = await page.locator('.card').count();
+ console.log(` cards rendered: ${cardCount}`);
+
+ // Activate buttons present?
+ const activateCount = await page.locator('.activate-btn').count();
+ console.log(` activate buttons: ${activateCount}`);
+
+ // STEP 2: click first card → modal
+ console.log('STEP 2: click first card');
+ if (cardCount > 0) {
+ // Click the card body (not the activate button). We click the .meta div to avoid stopPropagation issues.
+ await page.locator('.card').first().locator('.meta').click();
+ await page.waitForSelector('.modal-bg.open', { timeout: 5000 });
+ await page.waitForTimeout(800);
+ await page.screenshot({ path: path.join(OUT, '02-modal-first.png'), fullPage: false });
+ // Close the modal by clicking outside
+ await page.locator('.modal-bg').click({ position: { x: 10, y: 10 } });
+ await page.waitForTimeout(400);
+ }
+
+ // STEP 3: click third card (different visual_type)
+ console.log('STEP 3: click third card');
+ if (cardCount > 2) {
+ await page.locator('.card').nth(2).locator('.meta').click();
+ await page.waitForSelector('.modal-bg.open', { timeout: 5000 });
+ await page.waitForTimeout(800);
+ await page.screenshot({ path: path.join(OUT, '03-modal-third.png'), fullPage: false });
+ await page.locator('.modal-bg').click({ position: { x: 10, y: 10 } });
+ await page.waitForTimeout(400);
+ }
+
+ // STEP 4: click fifth card
+ console.log('STEP 4: click fifth card');
+ if (cardCount > 4) {
+ await page.locator('.card').nth(4).locator('.meta').click();
+ await page.waitForSelector('.modal-bg.open', { timeout: 5000 });
+ await page.waitForTimeout(800);
+ await page.screenshot({ path: path.join(OUT, '04-modal-fifth.png'), fullPage: false });
+ }
+
+ // Diagnostics
+ await fs.writeFile(path.join(OUT, 'console.log'), consoleMsgs.join('\n'), 'utf8');
+ const summary = {
+ viewer_status: 'reachable',
+ cards_rendered: cardCount,
+ activate_buttons: activateCount,
+ console_messages: consoleMsgs.length,
+ screenshots: ['01-gallery.png', '02-modal-first.png', '03-modal-third.png', '04-modal-fifth.png']
+ };
+ await fs.writeFile(path.join(OUT, 'summary.json'), JSON.stringify(summary, null, 2));
+ console.log('SUMMARY:', JSON.stringify(summary));
+
+ // Also probe orchestrator endpoints
+ const health = await (await fetch(`${ORCH}/health`)).json();
+ const runs = await (await fetch(`${ORCH}/runs`)).json();
+ console.log(`ORCH /health: ${JSON.stringify(health)}`);
+ console.log(`ORCH /runs: ${runs.runs?.length || 0} runs`);
+
+ await browser.close();
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..d5066fd
--- /dev/null
+++ b/server.js
@@ -0,0 +1,696 @@
+// Visual Factory orchestrator (:9892)
+
+require('dotenv').config();
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const os = require('node:os');
+const { spawn } = require('node:child_process');
+const express = require('express');
+const { Pool } = require('pg');
+
+const PG_DATABASE = process.env.PG_DATABASE || 'visual_factory';
+if (PG_DATABASE === 'dw_unified') {
+ console.error('[visual-factory] FATAL: PG_DATABASE=dw_unified is forbidden. Use the standalone `visual_factory` DB.');
+ process.exit(1);
+}
+
+const { runPipeline } = require('./src/pipeline');
+
+// In-process FIFO queue. Caps how many pipelines run concurrently so a flurry
+// of submissions doesn't crush Ollama / Playwright on this single Mac. Pending
+// runs sit at status='pending' (set by the INSERT) until promoted by pump().
+//
+// Codex P2 #13 fix: clamp to integer ≥1; 0/negative/NaN would silently deadlock
+// the queue (pump never moves anything) — fail fast on bad config.
+const PIPELINE_CONCURRENCY = (() => {
+ const raw = process.env.PIPELINE_CONCURRENCY;
+ const n = Number.parseInt(raw, 10);
+ if (raw !== undefined && (!Number.isFinite(n) || n < 1)) {
+ console.error(`[visual-factory] FATAL: PIPELINE_CONCURRENCY=${JSON.stringify(raw)} invalid; must be integer ≥1`);
+ process.exit(1);
+ }
+ return Number.isFinite(n) && n >= 1 ? n : 2;
+})();
+const _inFlight = new Set();
+const _queue = [];
+function enqueueRun(_pg, run) {
+ _queue.push(run);
+ _pump(_pg);
+}
+function _pump(_pg) {
+ while (_inFlight.size < PIPELINE_CONCURRENCY && _queue.length) {
+ const r = _queue.shift();
+ _inFlight.add(r.id);
+ runPipeline(_pg, r)
+ .catch(err => console.error('[visual-factory] pipeline crash:', err))
+ .finally(() => { _inFlight.delete(r.id); _pump(_pg); });
+ }
+}
+
+const app = express();
+app.use(express.json({ limit: '1mb' }));
+
+// Same-origin: the UI is served from this same Express, so CORS is unnecessary.
+// Kept commented as a record of the prior split-port architecture.
+// app.use((req, res, next) => { res.set('Access-Control-Allow-Origin','*'); ... });
+
+// Serve the static UI (public/index.html etc.) at the orchestrator root. This
+// collapses the previous 9892 (API) + 9893 (UI) split into a single port.
+app.use(express.static(path.join(__dirname, 'public')));
+
+const PORT = Number(process.env.ORCHESTRATOR_PORT || 9892);
+const PUBLISH_ROOT = process.env.PUBLISH_ROOT || path.join(os.homedir(), 'Pictures', 'visual-factory-published');
+const MANIFEST_PATH = path.join(PUBLISH_ROOT, 'manifest.json');
+const SANDBOX_OUTPUT = path.resolve(path.join(__dirname, 'output'));
+const PUBLISH_ROOT_RESOLVED = path.resolve(PUBLISH_ROOT);
+
+// Codex P1 #5/#6 fix: assert a path lives inside an allowlisted root before we
+// fs.copyFile / sendFile it. DB-resident paths can be poisoned (a buggy stage
+// or future bug could write `/etc/passwd` into artifacts.png_path); resolving
+// against a fixed root + path.sep prefix-check defeats relative-path traversal.
+function assertWithin(root, p) {
+ const resolvedRoot = path.resolve(root);
+ const resolved = path.resolve(p);
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
+ const err = new Error(`path "${p}" escapes sandbox "${root}"`);
+ err.code = 'EPATHESCAPE';
+ throw err;
+ }
+ return resolved;
+}
+
+// Codex P1 #5 fix: artifact_name must be safe for use in a filename. Mirror the
+// kebab-case rule the intake stage already applies, but enforce server-side
+// before constructing destination paths.
+const ARTIFACT_NAME_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
+function isSafeArtifactName(name) {
+ return typeof name === 'string' && ARTIFACT_NAME_RE.test(name);
+}
+
+// Codex P1 #9 fix: manifest.json is shared mutable state across activations.
+// A simple promise-chained mutex serializes read-modify-write so concurrent
+// activations don't lose entries. Same-process only — fine since the
+// orchestrator runs as a single Node process.
+let _manifestChain = Promise.resolve();
+function withManifestLock(fn) {
+ const next = _manifestChain.then(() => fn(), () => fn());
+ _manifestChain = next.catch(() => {});
+ return next;
+}
+
+// Append (upsert by artifact_name) an entry to the publish-folder manifest.
+// Best-effort: never throws — activation must succeed even if manifest write fails.
+// Serialized via withManifestLock so concurrent activations don't clobber each other.
+// Atomic write via .tmp + rename so a crash mid-write can't leave a corrupt file.
+async function appendManifest(entry) {
+ return withManifestLock(async () => {
+ try {
+ let arr = [];
+ try {
+ const raw = await fs.readFile(MANIFEST_PATH, 'utf8');
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed)) arr = parsed;
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
+ const idx = arr.findIndex(e => e.artifact_name === entry.artifact_name);
+ if (idx >= 0) arr[idx] = entry; else arr.push(entry);
+ const tmp = MANIFEST_PATH + '.tmp.' + process.pid;
+ await fs.writeFile(tmp, JSON.stringify(arr, null, 2), 'utf8');
+ await fs.rename(tmp, MANIFEST_PATH);
+ return { ok: true, count: arr.length };
+ } catch (err) {
+ return { ok: false, error: err.message };
+ }
+ });
+}
+
+// Codex P1 #7 fix: validate any URL we hand off to Norma to fetch. Reject
+// non-HTTP(S), private/loopback IPs, and metadata-service ranges so a poisoned
+// `image_url` can't pivot Norma into reading internal resources.
+function isAllowedExternalUrl(input) {
+ let u;
+ try { u = new URL(input); } catch { return false; }
+ if (!/^https?:$/.test(u.protocol)) return false;
+ const host = u.hostname.toLowerCase();
+ if (host === 'localhost' || host === '0.0.0.0') return false;
+ // IPv4 literal — block loopback + RFC1918 + link-local + AWS/GCP metadata.
+ const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
+ if (v4) {
+ const [, a, b] = v4.map(Number);
+ if (a === 127 || a === 10 || a === 0) return false;
+ if (a === 169 && b === 254) return false; // 169.254.169.254 metadata
+ if (a === 172 && b >= 16 && b <= 31) return false;
+ if (a === 192 && b === 168) return false;
+ }
+ // IPv6 literal — block loopback / link-local. Browsers wrap in [].
+ if (host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd')) return false;
+ return true;
+}
+
+// Reveal the published file in Finder. macOS-only; non-fatal if `open` is missing.
+function revealInFinder(filePath) {
+ try {
+ const child = spawn('open', ['-R', filePath], { detached: true, stdio: 'ignore' });
+ child.unref();
+ return true;
+ } catch { return false; }
+}
+
+// Norma instagram-agent (post.skill) host — single Instagram Business account.
+// Stays in simulation mode until IG_USER_ID + IG_ACCESS_TOKEN are set on Norma's
+// side. We just hand it a URL + caption and let it decide simulate vs real.
+//
+// Reconciled 2026-05-01: actual port is 9810 (not 9870 as skill docs originally
+// claimed). Norma uses Basic auth — default user `admin`, password from
+// AUTH_PASSWORD env (empty if unset). PID 26855 is the long-running healthy
+// instance; another pm2 entry with the same name crashes on require('express')
+// due to pm2's require-in-the-middle hook + root-shim conflict, but that
+// crashloop doesn't affect the running one.
+const NORMA_HOST = process.env.NORMA_HOST || 'http://127.0.0.1:9810';
+const NORMA_USER = process.env.NORMA_AUTH_USER || 'admin';
+const NORMA_PASS = process.env.NORMA_AUTH_PASS || '';
+const NORMA_AUTH_HEADER = 'Basic ' + Buffer.from(`${NORMA_USER}:${NORMA_PASS}`).toString('base64');
+const CLAUDE_CLI = process.env.CLAUDE_CLI || 'claude';
+
+async function assertExecutable(command) {
+ if (path.isAbsolute(command) || command.includes(path.sep)) {
+ await fs.access(command);
+ return;
+ }
+
+ for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
+ try {
+ await fs.access(path.join(dir, command));
+ return;
+ } catch {}
+ }
+ throw new Error(`${command} not found on PATH`);
+}
+
+function buildIgCaption(run, art) {
+ const lines = [];
+ if (run.purpose) lines.push(run.purpose);
+ if (run.domain && run.domain.includes('designerwallcoverings')) {
+ lines.push('', 'Designer Wallcoverings · One Stop Luxury Resource', '#DesignerWallcoverings #wallcovering #interiordesign');
+ } else if (run.domain) {
+ lines.push('', `via ${run.domain}`);
+ }
+ return lines.join('\n').trim() || (run.brief || '').slice(0, 280);
+}
+
+// Post the activated artifact to Norma's instagram-agent. Best-effort.
+// `imageUrl` should be a URL Norma can reach — for simulation mode any string
+// works; for real Meta-Graph posting it must be publicly fetchable.
+async function postToInstagramViaNorma({ run, art, imageUrl }) {
+ const caption = buildIgCaption(run, art);
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), 30_000);
+ try {
+ const r = await fetch(`${NORMA_HOST}/api/skill/post`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': NORMA_AUTH_HEADER,
+ },
+ body: JSON.stringify({ image_url: imageUrl, caption, pipeline_id: `vf-run-${run.id}` }),
+ signal: ctrl.signal
+ });
+ clearTimeout(t);
+ const body = await r.json().catch(() => ({}));
+ return { ok: r.ok && body.success !== false, status: r.status, caption, response: body };
+ } catch (err) {
+ clearTimeout(t);
+ return { ok: false, error: err.message, caption };
+ }
+}
+
+const poolCfg = {
+ host: process.env.PG_HOST || '/tmp',
+ port: Number(process.env.PG_PORT || 5432),
+ database: PG_DATABASE,
+ user: process.env.PG_USER || 'stevestudio2'
+};
+if (process.env.PG_PASSWORD) poolCfg.password = process.env.PG_PASSWORD;
+const pg = new Pool(poolCfg);
+
+app.get('/health', (_req, res) => {
+ res.json({ ok: true, service: 'visual-factory-orchestrator', port: PORT, db: PG_DATABASE });
+});
+
+// Deep health: probes every dependency the pipeline needs. The viewer hits this
+// on load so the user gets a clear "Ollama is down" message before submitting
+// a run that's doomed to fail at compose. Returns 503 when any required dep is
+// unhealthy so external monitors can alert.
+app.get('/health/deep', async (_req, res) => {
+ const checks = {
+ db: 'unknown',
+ ollama: 'unknown',
+ claude_cli: 'unknown',
+ playwright: 'unknown',
+ };
+ const ollamaModels = { compose: process.env.COMPOSE_MODEL || 'qwen3:14b', vision: process.env.VISION_MODEL || 'llava:latest' };
+
+ await Promise.allSettled([
+ pg.query('SELECT 1').then(() => { checks.db = 'ok'; }, e => { checks.db = `error: ${e.message}`; }),
+ (async () => {
+ try {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), 3000);
+ const r = await fetch(`${process.env.OLLAMA_HOST || 'http://127.0.0.1:11434'}/api/tags`, { signal: ctrl.signal });
+ clearTimeout(t);
+ if (!r.ok) { checks.ollama = `http ${r.status}`; return; }
+ const j = await r.json();
+ const have = new Set((j.models || []).map(m => m.name));
+ const missing = Object.values(ollamaModels).filter(m => !have.has(m));
+ checks.ollama = missing.length ? `ok but missing: ${missing.join(', ')}` : 'ok';
+ } catch (e) { checks.ollama = `error: ${e.message}`; }
+ })(),
+ (async () => {
+ try {
+ await assertExecutable(CLAUDE_CLI);
+ checks.claude_cli = 'ok';
+ } catch { checks.claude_cli = 'missing — critic stage will fail'; }
+ })(),
+ (async () => {
+ try {
+ require.resolve('playwright');
+ checks.playwright = 'ok';
+ } catch { checks.playwright = 'missing'; }
+ })(),
+ ]);
+
+ // Codex P1 #12 fix: every required dep gates `ok`. The route docstring says
+ // "probes every dependency the pipeline needs," so missing claude_cli or
+ // playwright must surface as 503, not silently green. Ollama "ok but missing"
+ // (model not pulled) also blocks ok — pipeline will fail at compose/vision.
+ const ok =
+ checks.db === 'ok' &&
+ checks.ollama === 'ok' &&
+ checks.claude_cli === 'ok' &&
+ checks.playwright === 'ok';
+ res.status(ok ? 200 : 503).json({
+ ok,
+ checks,
+ queue: { in_flight: _inFlight.size, queued: _queue.length, capacity: PIPELINE_CONCURRENCY },
+ });
+});
+
+app.get('/runs', async (_req, res) => {
+ try {
+ // Enrich with critic score (latest artifact) and last_error message (latest
+ // error event) so the UI can show both on the card without per-row fetches.
+ const { rows } = await pg.query(`
+ SELECT r.id, r.brief, r.domain, r.purpose, r.visual_type, r.artifact_name,
+ r.status, r.current_stage, r.width, r.height, r.created_at, r.finished_at,
+ a.score, e.message AS last_error
+ FROM runs r
+ LEFT JOIN LATERAL (
+ SELECT score FROM artifacts WHERE run_id = r.id ORDER BY id DESC LIMIT 1
+ ) a ON TRUE
+ LEFT JOIN LATERAL (
+ SELECT message FROM events
+ WHERE run_id = r.id AND level = 'error'
+ ORDER BY id DESC LIMIT 1
+ ) e ON TRUE
+ ORDER BY r.id DESC
+ LIMIT 100
+ `);
+ res.json({ runs: rows });
+ } catch (err) {
+ res.status(500).json({ error: err.message, hint: 'Did you createdb visual_factory + apply sql/001_init.sql?' });
+ }
+});
+
+app.post('/runs', async (req, res) => {
+ let { brief, domain, purpose, details } = req.body || {};
+ domain = (domain || '').trim();
+ purpose = (purpose || '').trim();
+ details = (details || '').trim();
+ brief = (brief || '').trim();
+
+ // If domain+purpose given, construct a brief. Free-text brief still wins if provided.
+ if (!brief) {
+ if (!domain || !purpose) {
+ return res.status(400).json({ error: 'either `brief` (string) OR `domain` + `purpose` required' });
+ }
+ brief = `Create a visual for **${domain}**.\n\nPurpose: ${purpose}.`;
+ if (details) brief += `\n\nDetails: ${details}`;
+ }
+
+ try {
+ const { rows } = await pg.query(
+ 'INSERT INTO runs (brief, domain, purpose) VALUES ($1,$2,$3) RETURNING *',
+ [brief, domain || null, purpose || null]
+ );
+ const run = rows[0];
+ enqueueRun(pg, run);
+ res.json({ run });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// Retry a failed / unapproved run. Resets status + current_stage, wipes its
+// events and artifact rows, deletes its output dir, then re-fires the pipeline
+// against the original brief/domain/purpose.
+//
+// Codex P0 #2 fix: refuse on `running`/`pending` (would race with an active
+// pipeline writing the same row); use an atomic UPDATE-with-WHERE-status guard
+// so two concurrent retry calls can't both proceed and double-fire the queue.
+app.post('/runs/:id/retry', async (req, res) => {
+ const runId = Number(req.params.id);
+ const force = req.query.force === 'true';
+ try {
+ const { rows: lookupRows } = await pg.query('SELECT * FROM runs WHERE id=$1', [runId]);
+ if (!lookupRows.length) return res.status(404).json({ error: 'run not found' });
+ const run = lookupRows[0];
+
+ // `running` / `pending` mean a live pipeline may still be writing this row
+ // (or it's queued behind one). Refuse unless force=true. `awaiting_activation*`
+ // has value worth preserving — delete-and-resubmit is the right move.
+ const retryable = /^(failed|completed_unapproved|unapproved)/.test(String(run.status));
+ if (!retryable && !force) {
+ return res.status(409).json({
+ error: `retry refused — status=${run.status}; pass ?force=true to override`,
+ hint: run.status === 'running' || run.status === 'pending'
+ ? 'live pipeline; force only after confirming the orchestrator no longer holds it'
+ : undefined,
+ });
+ }
+
+ // Atomic guard: only the first concurrent retry that flips this row from
+ // its eligible status into 'pending' wins. The losers see rowCount=0.
+ const eligibleStatusList = force
+ ? ['failed','failed_intake','failed_orphan','completed_unapproved','unapproved','running','pending']
+ : ['failed','failed_intake','failed_orphan','completed_unapproved','unapproved'];
+ const claim = await pg.query(
+ `UPDATE runs
+ SET status='pending', current_stage=0, finished_at=NULL, updated_at=now()
+ WHERE id=$1 AND status = ANY($2::text[])
+ RETURNING id`,
+ [runId, eligibleStatusList]
+ );
+ if (claim.rowCount === 0) {
+ return res.status(409).json({ error: 'retry race lost — status changed between read and claim; refresh and try again' });
+ }
+
+ await pg.query('DELETE FROM artifacts WHERE run_id=$1', [runId]);
+ await pg.query('DELETE FROM events WHERE run_id=$1', [runId]);
+ try { await fs.rm(path.join(__dirname, 'output', `run-${runId}`), { recursive: true, force: true }); } catch {}
+ enqueueRun(pg, { ...run, status: 'pending', current_stage: 0 });
+ res.json({ ok: true, runId });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// Hard-delete a run: cascades to events, removes artifact rows, wipes output dir.
+// Refuses on activated rows by default (since publishing dir lives outside output/
+// the activated PNG survives; but the audit trail is gone — that's the user's call).
+//
+// Codex P1 #4 fix: also refuse on `running`/`pending` unless force=true. Deleting
+// an in-flight run leaves the pipeline writing to a row that no longer exists,
+// which fails the next logEvent insert and races against the fs.rm of output/run-N.
+app.delete('/runs/:id', async (req, res) => {
+ const runId = Number(req.params.id);
+ const force = req.query.force === 'true';
+ try {
+ const { rows } = await pg.query('SELECT status FROM runs WHERE id=$1', [runId]);
+ if (!rows.length) return res.status(404).json({ error: 'run not found' });
+ const status = rows[0].status;
+ const inFlight = status === 'running' || status === 'pending';
+ if (status === 'activated' && !force) {
+ return res.status(409).json({ error: 'run is activated; pass ?force=true to delete anyway' });
+ }
+ if (inFlight && !force) {
+ return res.status(409).json({
+ error: `run is ${status}; deleting an in-flight run will race the pipeline. pass ?force=true if you've confirmed it's not actually running.`,
+ });
+ }
+ await pg.query('DELETE FROM artifacts WHERE run_id=$1', [runId]);
+ await pg.query('DELETE FROM runs WHERE id=$1', [runId]); // events CASCADE
+ try { await fs.rm(path.join(__dirname, 'output', `run-${runId}`), { recursive: true, force: true }); } catch {}
+ res.json({ ok: true, runId });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.get('/runs/:id/events', async (req, res) => {
+ try {
+ const { rows } = await pg.query(
+ 'SELECT id, stage, stage_name, level, message, payload, created_at FROM events WHERE run_id=$1 ORDER BY id ASC',
+ [Number(req.params.id)]
+ );
+ res.json({ events: rows });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// Static-serve sandbox PNGs so the viewer can show thumbnails.
+//
+// Codex P1 #6 fix: assert png_path lives under our output sandbox (or the
+// publish root for activated artifacts) before sendFile, so a poisoned DB row
+// can't make us serve arbitrary files (e.g., /etc/passwd).
+app.get('/runs/:id/png', async (req, res) => {
+ try {
+ const { rows } = await pg.query("SELECT png_path, published_path FROM artifacts WHERE run_id=$1 ORDER BY id DESC LIMIT 1", [Number(req.params.id)]);
+ if (!rows.length || !rows[0].png_path) return res.status(404).json({ error: 'no png yet' });
+ const candidate = rows[0].published_path || rows[0].png_path;
+ let safe;
+ try {
+ // Allow either the per-run sandbox (drafts) or the publish root (activated).
+ safe = (() => {
+ try { return assertWithin(SANDBOX_OUTPUT, candidate); }
+ catch { return assertWithin(PUBLISH_ROOT_RESOLVED, candidate); }
+ })();
+ } catch (e) {
+ console.error('[visual-factory] png path escapes sandbox:', candidate);
+ return res.status(403).json({ error: 'png path outside sandbox' });
+ }
+ res.sendFile(safe);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/**
+ * Post an activated run to Instagram via Norma's instagram-agent.
+ * Pre-conditions:
+ * - run must be 'activated' (we want a published_path that exists on disk)
+ * - Norma on :9870 reachable; if not, returns 502
+ * Caveats:
+ * - Norma is in simulation mode unless IG_USER_ID + IG_ACCESS_TOKEN set on Norma's side
+ * - Norma posts to a single Instagram Business account regardless of run.domain
+ * - For real posting, image_url must be publicly fetchable; we pass our own
+ * /runs/:id/png URL which is loopback-only, so real posting will fail at
+ * Meta until this PNG is hosted somewhere public. Simulation mode doesn't
+ * care — it just logs the URL.
+ */
+app.post('/runs/:id/post-ig', async (req, res) => {
+ const runId = Number(req.params.id);
+ try {
+ const { rows: runRows } = await pg.query('SELECT * FROM runs WHERE id=$1', [runId]);
+ if (!runRows.length) return res.status(404).json({ error: 'run not found' });
+ const run = runRows[0];
+ if (run.status !== 'activated' && req.query.force !== 'true') {
+ return res.status(409).json({ error: `run status is ${run.status}; post requires activated (or ?force=true)` });
+ }
+ const { rows: artRows } = await pg.query("SELECT * FROM artifacts WHERE run_id=$1 ORDER BY id DESC LIMIT 1", [runId]);
+ if (!artRows.length) return res.status(409).json({ error: 'no artifact for this run' });
+ const art = artRows[0];
+
+ // Loopback URL — Norma can fetch this if it lives on this Mac (same host),
+ // but Meta cannot reach it. For real posting, host the file publicly and
+ // override via req.body.image_url.
+ //
+ // Codex P1 #7 fix (SSRF): the override URL is forwarded to Norma, which
+ // fetches it. Reject anything that would let a poisoned body reach private
+ // ranges, file://, or AWS metadata. Only public http(s) hosts allowed.
+ let imageUrl = `http://127.0.0.1:${PORT}/runs/${runId}/png`;
+ if (req.body && typeof req.body.image_url === 'string' && req.body.image_url.length) {
+ if (!isAllowedExternalUrl(req.body.image_url)) {
+ return res.status(400).json({
+ error: 'image_url rejected — must be public http(s); private/loopback/metadata addresses blocked',
+ });
+ }
+ imageUrl = req.body.image_url;
+ }
+
+ const result = await postToInstagramViaNorma({ run, art, imageUrl });
+ await pg.query(
+ "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+ [runId, 9, 'social_post', result.ok ? 'info' : 'warn',
+ result.ok
+ ? `posted to IG (simulated=${result.response?.result?.simulated ?? 'unknown'}, media=${result.response?.result?.media_id || 'n/a'})`
+ : `IG post failed: ${result.error || 'http ' + result.status}`,
+ JSON.stringify(result)]
+ );
+ res.status(result.ok ? 200 : 502).json(result);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/**
+ * Manual activation: copy approved PNG (and HTML) to PUBLISH_ROOT.
+ * Gates: run must be `awaiting_activation*` unless ?force=true. Won't overwrite without ?overwrite=true.
+ */
+app.post('/runs/:id/activate', async (req, res) => {
+ const runId = Number(req.params.id);
+ const force = req.query.force === 'true';
+ const overwrite = req.query.overwrite === 'true';
+ try {
+ const { rows: runRows } = await pg.query('SELECT * FROM runs WHERE id=$1', [runId]);
+ if (!runRows.length) return res.status(404).json({ error: 'run not found' });
+ const run = runRows[0];
+ if (!force && !String(run.status).startsWith('awaiting_activation')) {
+ return res.status(409).json({ error: `run status is ${run.status}; activation requires awaiting_activation* (or ?force=true)` });
+ }
+ const { rows: artRows } = await pg.query("SELECT * FROM artifacts WHERE run_id=$1 ORDER BY id DESC LIMIT 1", [runId]);
+ if (!artRows.length) return res.status(409).json({ error: 'no artifact for this run' });
+ const art = artRows[0];
+
+ // Codex P1 #5 fix: validate every input before constructing destination
+ // paths. artifact_name from the DB could be poisoned with `../etc/passwd`
+ // — re-validate against the kebab-case rule. html_path needs the same
+ // sandbox check we already had on png_path.
+ if (!isSafeArtifactName(art.artifact_name)) {
+ return res.status(400).json({ error: `unsafe artifact_name: ${JSON.stringify(art.artifact_name)}` });
+ }
+ let srcPng;
+ try { srcPng = assertWithin(SANDBOX_OUTPUT, art.png_path); }
+ catch { return res.status(400).json({ error: 'png_path escapes sandbox' }); }
+ let srcHtml = null;
+ if (art.html_path) {
+ try { srcHtml = assertWithin(SANDBOX_OUTPUT, art.html_path); }
+ catch { return res.status(400).json({ error: 'html_path escapes sandbox' }); }
+ }
+
+ await fs.mkdir(PUBLISH_ROOT, { recursive: true });
+ // Resolve destinations and confirm they live under PUBLISH_ROOT_RESOLVED
+ // (defense-in-depth: artifact_name is already validated, but path.join
+ // could surprise us on weird edge cases).
+ let destPng, destHtml;
+ try {
+ destPng = assertWithin(PUBLISH_ROOT_RESOLVED, path.join(PUBLISH_ROOT, `${art.artifact_name}.png`));
+ destHtml = assertWithin(PUBLISH_ROOT_RESOLVED, path.join(PUBLISH_ROOT, `${art.artifact_name}.html`));
+ } catch { return res.status(400).json({ error: 'destination escapes publish root' }); }
+
+ let existed = false;
+ try { await fs.access(destPng); existed = true; } catch {}
+ if (existed && !overwrite) return res.status(409).json({ error: `target exists: ${destPng}`, hint: 'pass ?overwrite=true' });
+
+ // Codex P1 #8 fix: copy to .tmp first, only rename into place once both
+ // files are written. Then commit the DB rows. If any step fails, clean up
+ // tmp files so the publish dir doesn't end up with orphan halves.
+ const tmpPng = destPng + '.tmp.' + process.pid + '.' + Date.now();
+ const tmpHtml = srcHtml ? destHtml + '.tmp.' + process.pid + '.' + Date.now() : null;
+ let pngCommitted = false, htmlCommitted = false;
+ try {
+ await fs.copyFile(srcPng, tmpPng);
+ if (tmpHtml) await fs.copyFile(srcHtml, tmpHtml);
+ await fs.rename(tmpPng, destPng); pngCommitted = true;
+ if (tmpHtml) { await fs.rename(tmpHtml, destHtml); htmlCommitted = true; }
+ await pg.query("UPDATE artifacts SET status='activated', published_path=$1 WHERE id=$2", [destPng, art.id]);
+ await pg.query("UPDATE runs SET status='activated', updated_at=now() WHERE id=$1", [runId]);
+ } catch (err) {
+ // Best-effort rollback: drop the partials. If we already renamed into
+ // dest but DB failed, leave the file (manifest will be inconsistent for
+ // one entry, which is recoverable; deleting a published file is worse).
+ try { await fs.rm(tmpPng, { force: true }); } catch {}
+ if (tmpHtml) { try { await fs.rm(tmpHtml, { force: true }); } catch {} }
+ if (!pngCommitted) {
+ return res.status(500).json({ error: `activate failed before publish: ${err.message}` });
+ }
+ // We did publish PNG but failed later; still respond with a partial-failure status.
+ return res.status(500).json({
+ error: `activate partially succeeded: ${err.message}`,
+ png_committed: pngCommitted,
+ html_committed: htmlCommitted,
+ });
+ }
+ await pg.query(
+ "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+ [runId, 8, 'activate', 'info', existed ? 'overwrote target' : 'wrote target', JSON.stringify({ srcPng, destPng })]
+ );
+ await pg.query(
+ "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+ [runId, 8, 'activate', 'info', existed ? 'overwrote target' : 'wrote target', JSON.stringify({ srcPng, destPng })]
+ );
+
+ // Manifest write + Finder reveal happen AFTER the source-of-truth DB writes succeed.
+ // Both are best-effort — failures here don't roll back the activation.
+ const manifestEntry = {
+ run_id: runId,
+ artifact_name: art.artifact_name,
+ visual_type: art.visual_type,
+ domain: run.domain || null,
+ purpose: run.purpose || null,
+ brief: run.brief,
+ width: art.width,
+ height: art.height,
+ score: art.score,
+ dest_png: destPng,
+ dest_html: art.html_path ? destHtml : null,
+ activated_at: new Date().toISOString()
+ };
+ const manifestResult = await appendManifest(manifestEntry);
+ const finderOpened = req.query.no_finder === 'true' ? false : revealInFinder(destPng);
+ await pg.query(
+ "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+ [runId, 8, 'activate', manifestResult.ok ? 'info' : 'warn',
+ `manifest=${manifestResult.ok ? `count ${manifestResult.count}` : 'failed'}, finder=${finderOpened}`,
+ JSON.stringify({ manifest: manifestResult, finderOpened })]
+ );
+
+ // Optional one-shot IG post on activation (?post_ig=true). Best-effort.
+ // Norma is in simulation mode by default — see instagram-post-scheduler skill.
+ let igResult = null;
+ if (req.query.post_ig === 'true') {
+ const imageUrl = `http://127.0.0.1:${PORT}/runs/${runId}/png`;
+ igResult = await postToInstagramViaNorma({ run, art, imageUrl });
+ await pg.query(
+ "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+ [runId, 9, 'social_post', igResult.ok ? 'info' : 'warn',
+ igResult.ok
+ ? `auto-posted to IG (simulated=${igResult.response?.result?.simulated ?? 'unknown'})`
+ : `auto IG post failed: ${igResult.error || 'http ' + igResult.status}`,
+ JSON.stringify(igResult)]
+ );
+ }
+
+ res.json({
+ ok: true,
+ srcPng,
+ destPng,
+ destHtml,
+ existed_before: existed,
+ manifest_path: MANIFEST_PATH,
+ manifest_ok: manifestResult.ok,
+ finder_opened: finderOpened,
+ ig_posted: igResult ? igResult.ok : null,
+ ig_result: igResult
+ });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// Boot-time orphan cleanup: any run still at 'running' or 'pending' is by
+// definition orphaned, because at boot the in-memory queue and in-flight set
+// are both empty — no live process is working on them. Drop the 10-min age
+// filter (which would strand recent crashes); mark every non-terminal row.
+// Codex P0 #1 fix.
+async function reapOrphans() {
+ try {
+ const { rowCount, rows } = await pg.query(
+ `UPDATE runs
+ SET status='failed_orphan', finished_at=now(), updated_at=now()
+ WHERE status IN ('running','pending')
+ RETURNING id, current_stage`
+ );
+ if (rowCount > 0) {
+ console.log(`[visual-factory] reaped ${rowCount} orphan run(s):`, rows.map(r => `#${r.id}@stage${r.current_stage}`).join(', '));
+ for (const r of rows) {
+ await pg.query(
+ 'INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)',
+ [r.id, r.current_stage, 'reap', 'error', 'orchestrator restarted while this run was in flight; marked orphaned', null]
+ );
+ }
+ }
+ } catch (err) { console.error('[visual-factory] reapOrphans failed:', err.message); }
+}
+
+app.listen(PORT, '127.0.0.1', async () => {
+ console.log(`[visual-factory] orchestrator listening on http://127.0.0.1:${PORT} (db=${PG_DATABASE})`);
+ await reapOrphans();
+});
diff --git a/sql/001_init.sql b/sql/001_init.sql
new file mode 100644
index 0000000..da7e34a
--- /dev/null
+++ b/sql/001_init.sql
@@ -0,0 +1,47 @@
+-- Visual Factory schema. Standalone PG `visual_factory` DB. NEVER point at dw_unified.
+-- Bootstrap: createdb visual_factory && psql -d visual_factory -f sql/001_init.sql
+
+CREATE TABLE IF NOT EXISTS runs (
+ id BIGSERIAL PRIMARY KEY,
+ brief TEXT NOT NULL,
+ domain TEXT,
+ purpose TEXT,
+ visual_type TEXT,
+ artifact_name TEXT,
+ status TEXT NOT NULL DEFAULT 'pending',
+ current_stage INTEGER NOT NULL DEFAULT 0,
+ width INTEGER,
+ height INTEGER,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ finished_at TIMESTAMPTZ
+);
+
+CREATE TABLE IF NOT EXISTS events (
+ id BIGSERIAL PRIMARY KEY,
+ run_id BIGINT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
+ stage INTEGER NOT NULL,
+ stage_name TEXT NOT NULL,
+ level TEXT NOT NULL DEFAULT 'info',
+ message TEXT,
+ payload JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS artifacts (
+ id BIGSERIAL PRIMARY KEY,
+ run_id BIGINT REFERENCES runs(id) ON DELETE SET NULL,
+ visual_type TEXT NOT NULL,
+ artifact_name TEXT NOT NULL,
+ html_path TEXT,
+ png_path TEXT,
+ width INTEGER,
+ height INTEGER,
+ score INTEGER,
+ status TEXT NOT NULL DEFAULT 'draft',
+ published_path TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS events_run_idx ON events (run_id, stage);
+CREATE INDEX IF NOT EXISTS artifacts_name_idx ON artifacts (visual_type, artifact_name);
diff --git a/src/llm.js b/src/llm.js
new file mode 100644
index 0000000..548053e
--- /dev/null
+++ b/src/llm.js
@@ -0,0 +1,128 @@
+// Visual Factory — LLM adapters.
+// All-local, all-free. No Anthropic API.
+// ollama() → qwen3:14b for spec/HTML/iterate; llava for vision
+// claudeCli() → critic only (one-call-per-run, Max sub)
+
+const { spawn } = require('node:child_process');
+const fs = require('node:fs/promises');
+
+const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
+const CLAUDE_CLI = process.env.CLAUDE_CLI || 'claude';
+
+async function ollama({ model, system, prompt, format = null, temperature = 0.2, images = null, signal = null }) {
+ const message = { role: 'user', content: prompt };
+ if (images && images.length) {
+ message.images = await Promise.all(images.map(async p => {
+ const buf = await fs.readFile(p);
+ return buf.toString('base64');
+ }));
+ }
+ // num_predict caps llava's output to prevent the degenerate "1000 empty strings"
+ // mode that breaks JSON parsing. 1500 tokens is plenty for the small JSON we want.
+ const body = {
+ model,
+ stream: false,
+ options: { temperature, num_predict: 1500 },
+ messages: [
+ ...(system ? [{ role: 'system', content: system }] : []),
+ message
+ ]
+ };
+ if (format === 'json') body.format = 'json';
+
+ // Retry transient fetch failures (socket drops under concurrent load).
+ // Backoff: 2s, 5s, 12s.
+ const delays = [2000, 5000, 12000];
+ let lastErr;
+ for (let attempt = 0; attempt <= delays.length; attempt++) {
+ if (signal?.aborted) throw new Error('ollama call aborted (stage timeout or cancellation)');
+ try {
+ const res = await fetch(`${OLLAMA_HOST}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: signal || undefined
+ });
+ if (!res.ok) {
+ const txt = await res.text();
+ // Treat 5xx as transient; 4xx as permanent
+ if (res.status >= 500 && attempt < delays.length) {
+ lastErr = new Error(`ollama ${model} http ${res.status}: ${txt.slice(0, 200)}`);
+ } else {
+ throw new Error(`ollama ${model} http ${res.status}: ${txt.slice(0, 200)}`);
+ }
+ } else {
+ const data = await res.json();
+ const content = data?.message?.content ?? '';
+ if (format === 'json') {
+ try { return JSON.parse(content); }
+ catch (e) { throw new Error(`ollama ${model} did not return valid JSON: ${content.slice(0, 200)}`); }
+ }
+ return content;
+ }
+ } catch (err) {
+ lastErr = err;
+ // AbortError = caller cancelled (stage timeout). Don't retry.
+ if (err?.name === 'AbortError' || signal?.aborted) {
+ throw new Error('ollama call aborted (stage timeout or cancellation)');
+ }
+ // TypeError: fetch failed = socket-level. Retry.
+ if (!(err instanceof TypeError) && !err.message?.includes('ollama') ) throw err;
+ if (attempt === delays.length) break;
+ }
+ // Honor abort during back-off too — otherwise a cancelled stage waits the full 19s.
+ await new Promise((resolve, reject) => {
+ const t = setTimeout(resolve, delays[attempt]);
+ if (signal) {
+ const onAbort = () => { clearTimeout(t); reject(new Error('ollama call aborted (during retry back-off)')); };
+ signal.addEventListener('abort', onAbort, { once: true });
+ }
+ });
+ }
+ throw lastErr || new Error(`ollama ${model} failed after retries`);
+}
+
+function claudeCli({ prompt, timeoutMs = 180000, signal = null }) {
+ // Pipe prompt via stdin instead of the `-p` CLI flag — avoids arg-length / quoting
+ // issues when the prompt contains JSON, newlines, or non-ASCII chars.
+ // signal: external AbortSignal (e.g., from stage timeout) — kills the child.
+ return new Promise((resolve, reject) => {
+ if (signal?.aborted) return reject(new Error('claude CLI aborted before spawn'));
+ const child = spawn(CLAUDE_CLI, ['-p'], { stdio: ['pipe', 'pipe', 'pipe'] });
+ let stdout = '';
+ let stderr = '';
+ let settled = false;
+ const cleanup = () => {
+ clearTimeout(timer);
+ if (signal && onAbort) signal.removeEventListener('abort', onAbort);
+ };
+ const finish = (fn, val) => { if (settled) return; settled = true; cleanup(); fn(val); };
+
+ const timer = setTimeout(() => {
+ try { child.kill('SIGTERM'); } catch {}
+ finish(reject, new Error(`claude CLI timed out after ${timeoutMs}ms`));
+ }, timeoutMs);
+
+ let onAbort = null;
+ if (signal) {
+ onAbort = () => {
+ try { child.kill('SIGTERM'); } catch {}
+ // SIGKILL fallback if it doesn't exit cleanly within 2s.
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000).unref?.();
+ finish(reject, new Error('claude CLI aborted (stage timeout or cancellation)'));
+ };
+ signal.addEventListener('abort', onAbort, { once: true });
+ }
+
+ child.stdout.on('data', d => { stdout += d; });
+ child.stderr.on('data', d => { stderr += d; });
+ child.on('error', err => finish(reject, new Error(`claude CLI spawn error: ${err.message}`)));
+ child.on('close', code => {
+ if (code !== 0) return finish(reject, new Error(`claude CLI exit ${code}: ${stderr.slice(0, 400) || '(empty stderr)'}`));
+ finish(resolve, stdout.trim());
+ });
+ try { child.stdin.end(prompt); } catch (e) { finish(reject, new Error(`claude CLI stdin write failed: ${e.message}`)); }
+ });
+}
+
+module.exports = { ollama, claudeCli };
diff --git a/src/pipeline.js b/src/pipeline.js
new file mode 100644
index 0000000..6daa27b
--- /dev/null
+++ b/src/pipeline.js
@@ -0,0 +1,236 @@
+// Visual Factory pipeline. 9 stages.
+// 1 intake → 2 design_system (folded into intake) → 3 compose → 4 render
+// → 5 vision_check ‖ 6 critic-prep → 6 critic → 7 iterate (re-render+re-vision+re-critic)
+// → 8 catalog → 9 activate (manual gate, separate endpoint)
+
+const fs = require('node:fs/promises');
+const { runIntake } = require('./stages/intake');
+const { runCompose } = require('./stages/compose');
+const { runRender } = require('./stages/render');
+const { runVisionCheck } = require('./stages/vision_check');
+const { runCritic } = require('./stages/critic');
+const { runIterate } = require('./stages/iterate');
+
+const STAGES = ['intake','compose','render','vision_check','critic','iterate','catalog','activate','cleanup'];
+
+// Per-stage timeouts (ms). Tuned for local Ollama / Playwright / Claude CLI on
+// the Mac Studio. If a stage hangs longer than this, abort the whole run so the
+// UI doesn't show "running" forever. Critic + compose get longer budgets
+// because qwen3:14b on a complex HTML brief or claude CLI on a tricky review
+// can legitimately take 60–90s.
+const STAGE_TIMEOUT_MS = {
+ intake: 45_000,
+ compose: 180_000,
+ render: 90_000,
+ vision_check: 90_000,
+ critic: 120_000,
+ iterate: 300_000,
+};
+
+// Codex P0 #3 fix: real cancellation. Each stage receives an AbortSignal that
+// the underlying ollama fetch / Playwright browser / Claude CLI child honors,
+// so when the timeout fires we actually kill the work instead of leaking a
+// background browser/child holding GPU/CPU while the queue moves on.
+//
+// Usage: `await withTimeout('compose', signal => runCompose({runId, spec, signal}))`
+function withTimeout(name, factory) {
+ const ms = STAGE_TIMEOUT_MS[name] || 60_000;
+ const controller = new AbortController();
+ let timer;
+ const timeoutP = new Promise((_, rej) => {
+ timer = setTimeout(() => {
+ controller.abort();
+ rej(new Error(`stage "${name}" timed out after ${ms}ms`));
+ }, ms);
+ });
+ // Run factory inside try to convert sync throws to rejections.
+ let workP;
+ try { workP = Promise.resolve(factory(controller.signal)); }
+ catch (e) { workP = Promise.reject(e); }
+ return Promise.race([workP, timeoutP]).finally(() => clearTimeout(timer));
+}
+
+async function logEvent(pg, runId, stage, name, level, message, payload) {
+ await pg.query(
+ 'INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)',
+ [runId, stage, name, level, message, payload ? JSON.stringify(payload) : null]
+ );
+}
+async function setStage(pg, runId, stage, status) {
+ await pg.query('UPDATE runs SET current_stage=$1, status=$2, updated_at=now() WHERE id=$3', [stage, status, runId]);
+}
+async function finishRun(pg, runId, status) {
+ await pg.query('UPDATE runs SET status=$1, finished_at=now(), updated_at=now() WHERE id=$2', [status, runId]);
+}
+
+async function runPipeline(pg, run) {
+ const runId = run.id;
+ // Codex P1 #11 fix: track the actual current stage locally so the crash
+ // handler logs the right one (run.current_stage is frozen at runPipeline
+ // call time and won't reflect the stage we were in when we threw).
+ let currentStage = run.current_stage || 0;
+ let currentStageName = STAGES[currentStage - 1] || 'unknown';
+ const trackStage = (n, name) => { currentStage = n; currentStageName = name; };
+ try {
+ // 1: intake
+ trackStage(1, 'intake');
+ await setStage(pg, runId, 1, 'running');
+ await logEvent(pg, runId, 1, 'intake', 'info', 'starting', null);
+ const { spec, errors: ie } = await withTimeout('intake', signal => runIntake({ brief: run.brief, signal }));
+ if (ie.length) {
+ await logEvent(pg, runId, 1, 'intake', 'error', 'spec validation failed', { spec, errors: ie });
+ await finishRun(pg, runId, 'failed_intake');
+ return;
+ }
+ await pg.query('UPDATE runs SET visual_type=$1, artifact_name=$2, width=$3, height=$4, updated_at=now() WHERE id=$5',
+ [spec.visual_type, spec.artifact_name, spec.width, spec.height, runId]);
+ await logEvent(pg, runId, 1, 'intake', 'info', 'spec extracted', { spec });
+
+ // 2 (folded into intake — palette/typography/layout already in spec)
+
+ // 3: compose
+ trackStage(2, 'compose');
+ await setStage(pg, runId, 2, 'running');
+ await logEvent(pg, runId, 2, 'compose', 'info', 'starting', null);
+ let composed = await withTimeout('compose', signal => runCompose({ runId, spec, signal }));
+ if (composed.errors.length) {
+ await logEvent(pg, runId, 2, 'compose', 'warn', 'validation issues', { errors: composed.errors });
+ } else {
+ await logEvent(pg, runId, 2, 'compose', 'info', 'wrote html', { htmlPath: composed.htmlPath, bytes: composed.html.length });
+ }
+
+ // 4: render
+ trackStage(3, 'render');
+ await setStage(pg, runId, 3, 'running');
+ await logEvent(pg, runId, 3, 'render', 'info', 'starting (playwright headless)', null);
+ let rendered = await withTimeout('render', signal => runRender({ htmlPath: composed.htmlPath, width: spec.width, height: spec.height, signal }));
+ await logEvent(pg, runId, 3, 'render', 'info', 'wrote png', { pngPath: rendered.pngPath, bytes: rendered.bytes });
+
+ await pg.query(
+ 'INSERT INTO artifacts (run_id, visual_type, artifact_name, html_path, png_path, width, height, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)',
+ [runId, spec.visual_type, spec.artifact_name, composed.htmlPath, rendered.pngPath, spec.width, spec.height, 'draft']
+ );
+
+ // 5: vision_check
+ trackStage(4, 'vision_check');
+ await setStage(pg, runId, 4, 'running');
+ await logEvent(pg, runId, 4, 'vision_check', 'info', 'starting (llava)', null);
+ let vision = await withTimeout('vision_check', signal => runVisionCheck({ pngPath: rendered.pngPath, signal }));
+ await logEvent(pg, runId, 4, 'vision_check', vision.issues.length ? 'warn' : 'info',
+ `quality=${vision.overall_quality} text_seen=${vision.text_content.length}`, vision);
+
+ // 6: critic
+ trackStage(5, 'critic');
+ await setStage(pg, runId, 5, 'running');
+ await logEvent(pg, runId, 5, 'critic', 'info', 'starting (claude CLI)', null);
+ let review;
+ try {
+ review = await withTimeout('critic', signal => runCritic({ brief: run.brief, spec, vision, signal }));
+ await logEvent(pg, runId, 5, 'critic', review.approved ? 'info' : 'warn',
+ `score=${review.score} approved=${review.approved}`, { review });
+ } catch (err) {
+ review = { approved: false, score: 0, issues: [`critic call failed: ${err.message}`], fix_instructions: [] };
+ await logEvent(pg, runId, 5, 'critic', 'error', 'critic call failed', { error: err.message });
+ }
+
+ // 7: iterate — loop up to MAX_ITERATIONS times, breaking on approval.
+ // Each round: apply fixes → re-render → re-vision → re-critic.
+ // Track iteration count so the UI can label v2/v3/etc and we can cap budget.
+ trackStage(6, 'iterate');
+ await setStage(pg, runId, 6, 'running');
+ let finalReview = review;
+ let iterationCount = 0;
+ const MAX_ITERATIONS = Number(process.env.MAX_ITERATIONS || 3);
+
+ while (
+ !finalReview.approved &&
+ Array.isArray(finalReview.fix_instructions) &&
+ finalReview.fix_instructions.length &&
+ iterationCount < MAX_ITERATIONS
+ ) {
+ const iterNum = iterationCount + 1;
+ await logEvent(pg, runId, 6, 'iterate', 'info',
+ `iteration ${iterNum}/${MAX_ITERATIONS}: applying ${finalReview.fix_instructions.length} fixes (prev score=${finalReview.score})`,
+ { iteration: iterNum, fixes: finalReview.fix_instructions });
+ try {
+ const iter = await withTimeout('iterate', signal => runIterate({ htmlPath: composed.htmlPath, html: composed.html, spec, review: finalReview, signal }));
+ composed.html = iter.html;
+ await logEvent(pg, runId, 6, 'iterate', 'info', `iter ${iterNum}: rewrote html`, { iteration: iterNum, bytes: iter.html.length });
+
+ rendered = await withTimeout('render', signal => runRender({ htmlPath: composed.htmlPath, width: spec.width, height: spec.height, signal }));
+ await logEvent(pg, runId, 6, 'iterate', 'info', `iter ${iterNum}: re-rendered png`, { iteration: iterNum, bytes: rendered.bytes });
+
+ vision = await withTimeout('vision_check', signal => runVisionCheck({ pngPath: rendered.pngPath, signal }));
+ await logEvent(pg, runId, 6, 'iterate', vision.issues.length ? 'warn' : 'info',
+ `iter ${iterNum}: re-vision quality=${vision.overall_quality}`, { iteration: iterNum, ...vision });
+
+ // Codex P1 #10 fix: if re-critic fails, the rewritten HTML/PNG is already
+ // on disk but we can't fairly score it. Mark the run failed_iteration
+ // and bail — better than cataloging a stale review against a brand-new
+ // artifact (the user would see a v2 with score from v1).
+ try {
+ const reviewN = await withTimeout('critic', signal => runCritic({ brief: run.brief, spec, vision, signal }));
+ await logEvent(pg, runId, 6, 'iterate', reviewN.approved ? 'info' : 'warn',
+ `iter ${iterNum}: re-critic score=${reviewN.score} approved=${reviewN.approved} (was ${finalReview.score})`,
+ { iteration: iterNum, review: reviewN });
+ finalReview = reviewN;
+ } catch (err) {
+ await logEvent(pg, runId, 6, 'iterate', 'error', `iter ${iterNum}: re-critic failed — abandoning run`, { iteration: iterNum, error: err.message });
+ await pg.query("UPDATE artifacts SET status='failed_iteration' WHERE run_id=$1", [runId]);
+ await finishRun(pg, runId, 'failed_iteration');
+ return; // skip catalog/activate — the artifact never got a fair review
+ }
+ iterationCount = iterNum;
+ } catch (err) {
+ await logEvent(pg, runId, 6, 'iterate', 'error', `iter ${iterNum}: failed mid-loop`, { iteration: iterNum, error: err.message });
+ // Same reasoning: a partial iteration left the artifact in an unknown
+ // state vs the spec. Don't catalog it as awaiting_activation.
+ await pg.query("UPDATE artifacts SET status='failed_iteration' WHERE run_id=$1", [runId]);
+ await finishRun(pg, runId, 'failed_iteration');
+ return;
+ }
+ }
+
+ if (iterationCount === 0) {
+ await logEvent(pg, runId, 6, 'iterate', 'info',
+ review.approved ? 'critic approved — no iteration needed' : 'no fix_instructions to act on', null);
+ } else if (!finalReview.approved && iterationCount >= MAX_ITERATIONS) {
+ await logEvent(pg, runId, 6, 'iterate', 'warn',
+ `hit MAX_ITERATIONS=${MAX_ITERATIONS} without approval (final score=${finalReview.score})`, null);
+ }
+
+ // 8: catalog (update artifact row with final score + status)
+ trackStage(7, 'catalog');
+ await setStage(pg, runId, 7, 'running');
+ await pg.query("UPDATE artifacts SET score=$1, status=$2 WHERE run_id=$3",
+ [finalReview.score, finalReview.approved ? 'awaiting_activation' : 'unapproved', runId]);
+ await logEvent(pg, runId, 7, 'catalog', 'info', `final score=${finalReview.score}`, null);
+
+ // 9: activate (manual gate — see POST /runs/:id/activate)
+ trackStage(8, 'activate');
+ await setStage(pg, runId, 8, 'running');
+ await logEvent(pg, runId, 8, 'activate', 'info', 'awaiting manual activation (POST /runs/:id/activate)', null);
+
+ const finalStatus = finalReview.approved
+ ? (iterationCount > 0 ? `awaiting_activation_v${iterationCount + 1}` : 'awaiting_activation')
+ : 'completed_unapproved';
+ await finishRun(pg, runId, finalStatus);
+ } catch (err) {
+ // Codex P1 #11 fix: log against the stage we were ACTUALLY in (tracked
+ // locally), not run.current_stage which is frozen at function-entry. And
+ // wrap the logEvent in its own try so a logging failure can't skip the
+ // finishRun — leaving the run stuck "running" is the worst outcome.
+ try {
+ await logEvent(pg, runId, currentStage, currentStageName, 'error', err.message, { stack: err.stack, stage: currentStage });
+ } catch (logErr) {
+ console.error('[visual-factory] crash handler logEvent failed:', logErr.message);
+ }
+ try {
+ await finishRun(pg, runId, 'failed');
+ } catch (finishErr) {
+ console.error('[visual-factory] crash handler finishRun failed:', finishErr.message);
+ }
+ }
+}
+
+module.exports = { STAGES, runPipeline };
diff --git a/src/stages/compose.js b/src/stages/compose.js
new file mode 100644
index 0000000..9197acb
--- /dev/null
+++ b/src/stages/compose.js
@@ -0,0 +1,57 @@
+// Stage 3 — compose. qwen3:14b writes a single self-contained HTML+CSS doc that fills
+// the target dimensions exactly and respects the spec's palette/typography/content.
+
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const { ollama } = require('../llm');
+
+const SANDBOX_ROOT = path.join(__dirname, '..', '..', 'output');
+
+const SYSTEM = `You write a SINGLE self-contained HTML document that renders a polished visual at exact target dimensions. Output ONLY raw HTML — no code fences, no commentary.
+
+REQUIREMENTS:
+- One <!DOCTYPE html> file, all CSS inline in <style>, no external assets, no JS frameworks.
+- The visible canvas is exactly width × height pixels. Use:
+ body { margin: 0; width: <W>px; height: <H>px; overflow: hidden; }
+- Use Google Fonts via <link> for typography ("serif"=Playfair Display, "sans"=Inter, "display"=Bebas Neue, "mono"=JetBrains Mono).
+- Honor the spec.palette colors precisely.
+- Render every content_block. Use spec.background ("solid"/"gradient"/"pattern") for the canvas.
+- **TEXT MUST READ.** If background is "pattern", confine the pattern to the EDGES (e.g., a top/bottom band, an asymmetric corner block) so headline + subhead sit on a clean solid panel — NEVER tile a busy stripe/dot pattern under the type. Headlines must hit AAA contrast against whatever is behind them.
+- For image_placeholder blocks, render a styled container with subtle texture (CSS gradients/patterns). Never reference external image URLs.
+- Accessibility-grade contrast on all text.
+- No lorem ipsum — use the block.text values verbatim.
+- Polished, intentional layout. NOT a generic landing-page hero.`;
+
+async function runCompose({ runId, spec }) {
+ const userPrompt = `Spec:
+${JSON.stringify(spec, null, 2)}
+
+Width: ${spec.width}px
+Height: ${spec.height}px
+
+Write the HTML now.`;
+
+ const html = stripFences(await ollama({
+ model: process.env.COMPOSE_MODEL || 'qwen3:14b',
+ system: SYSTEM,
+ prompt: userPrompt,
+ temperature: 0.4
+ })).trim();
+
+ const runDir = path.join(SANDBOX_ROOT, `run-${runId}`);
+ await fs.mkdir(runDir, { recursive: true });
+ const htmlPath = path.join(runDir, `${spec.artifact_name}.html`);
+ await fs.writeFile(htmlPath, html, 'utf8');
+
+ const errors = [];
+ if (!/<!DOCTYPE\s+html/i.test(html)) errors.push('missing <!DOCTYPE html>');
+ if (!/<style[\s>]/i.test(html)) errors.push('missing inline <style>');
+ if (html.length < 400) errors.push(`html too short (${html.length} bytes)`);
+ return { htmlPath, html, errors };
+}
+
+function stripFences(s) {
+ return s.replace(/^```(?:html)?\s*\n/i, '').replace(/\n```\s*$/, '');
+}
+
+module.exports = { runCompose };
diff --git a/src/stages/critic.js b/src/stages/critic.js
new file mode 100644
index 0000000..904bf63
--- /dev/null
+++ b/src/stages/critic.js
@@ -0,0 +1,52 @@
+// Stage 6 — critic. Single Claude CLI subprocess call. Compares the llava description
+// of the rendered image against the original brief + spec; returns a JSON review.
+
+const { claudeCli } = require('../llm');
+
+const PROMPT = (brief, spec, vision) => `You are reviewing an automated visual generation.
+
+IMPORTANT CALIBRATION: The vision-model description below is from a small local model (llava). It frequently hallucinates colors and misses text. Treat \`vision.text_content\` as a LOWER BOUND on what's actually rendered, not the truth — the renderer wrote real HTML with the spec's content blocks, so they ARE present even if vision didn't read them. Treat \`vision.palette\` and \`vision.mood\` as soft signals, not facts. The strongest reliable signals are:
+ - \`vision.overall_quality\` (the model's holistic read — usually trustworthy)
+ - \`vision.issues\` (only if it flagged something concrete like clipping/overflow)
+
+ORIGINAL BRIEF:
+${brief}
+
+EXTRACTED SPEC:
+${JSON.stringify(spec, null, 2)}
+
+VISION-MODEL REPORT (treat per the calibration above):
+${JSON.stringify(vision, null, 2)}
+
+Decision policy:
+ - If \`vision.overall_quality\` ≥ 75 AND \`vision.issues\` is empty/minor → APPROVE (score = vision.overall_quality)
+ - If \`vision.issues\` flags clipping, broken layout, or lorem ipsum → REJECT with concrete fixes
+ - Don't reject for "missing palette colors" or "missing text" based on vision alone — those are unreliable
+ - Don't reject for subjective mood mismatch alone
+
+Return ONLY a JSON object, no prose, no fences:
+{
+ "approved": <true|false>,
+ "score": <0-100>,
+ "issues": [<short string per concrete problem actually evidenced>],
+ "fix_instructions": [<short string per actionable HTML/CSS revision; empty if approved>]
+}`;
+
+async function runCritic({ brief, spec, vision, signal = null }) {
+ const raw = await claudeCli({ prompt: PROMPT(brief, spec, vision), timeoutMs: 180000, signal });
+ const m = raw.match(/\{[\s\S]*\}/);
+ if (!m) return { approved: false, score: 0, issues: ['critic returned no JSON'], fix_instructions: [], raw_excerpt: raw.slice(0, 300) };
+ try {
+ const r = JSON.parse(m[0]);
+ return {
+ approved: !!r.approved,
+ score: Number(r.score) || 0,
+ issues: Array.isArray(r.issues) ? r.issues : [],
+ fix_instructions: Array.isArray(r.fix_instructions) ? r.fix_instructions : []
+ };
+ } catch (e) {
+ return { approved: false, score: 0, issues: [`critic JSON parse failed: ${e.message}`], fix_instructions: [] };
+ }
+}
+
+module.exports = { runCritic };
diff --git a/src/stages/intake.js b/src/stages/intake.js
new file mode 100644
index 0000000..81059fd
--- /dev/null
+++ b/src/stages/intake.js
@@ -0,0 +1,43 @@
+// Stage 1 — intake. Brief → structured visual spec.
+
+const { ollama } = require('../llm');
+
+const SYSTEM = `You convert a visual brief into a structured spec for "The Visual Factory".
+The Factory composes HTML+CSS, screenshots it via Playwright, and reviews the PNG.
+Return ONLY a JSON object:
+ visual_type "social_post" | "hero_image" | "poster" | "diagram" | "card" | "ad_creative"
+ artifact_name short kebab-case identifier
+ width integer pixels (default 1200)
+ height integer pixels (default 1200; for social_post 1080; hero 1200x600; poster 1200x1500)
+ palette array of 3-5 hex colors that fit the mood
+ mood one short phrase (e.g. "minimalist editorial", "bold maximalist")
+ typography "serif" | "sans" | "display" | "mono"
+ content_blocks array of objects: {kind: "headline"|"subhead"|"body"|"cta"|"image_placeholder", text: "..."} (max 6)
+ background "solid" | "gradient" | "pattern"
+No prose, no markdown, no code fences.`;
+
+const KEBAB = /^[a-z0-9-]{2,60}$/;
+
+async function runIntake({ brief }) {
+ const spec = await ollama({
+ model: process.env.COMPOSE_MODEL || 'qwen3:14b',
+ system: SYSTEM,
+ prompt: brief,
+ format: 'json',
+ temperature: 0.2
+ });
+
+ const errors = [];
+ if (!spec.visual_type || !['social_post','hero_image','poster','diagram','card','ad_creative'].includes(spec.visual_type)) {
+ errors.push(`visual_type invalid: ${JSON.stringify(spec.visual_type)}`);
+ }
+ if (!spec.artifact_name || !KEBAB.test(spec.artifact_name)) errors.push(`artifact_name invalid: ${JSON.stringify(spec.artifact_name)}`);
+ spec.width = Number(spec.width) || 1200;
+ spec.height = Number(spec.height) || 1200;
+ spec.palette = Array.isArray(spec.palette) ? spec.palette.filter(c => /^#?[0-9a-fA-F]{6}$/.test(String(c))).map(c => c.startsWith('#') ? c : `#${c}`).slice(0, 5) : [];
+ if (!spec.palette.length) spec.palette = ['#0e0e10', '#e8e8ea', '#d4af37'];
+ spec.content_blocks = Array.isArray(spec.content_blocks) ? spec.content_blocks.slice(0, 6) : [];
+ return { spec, errors };
+}
+
+module.exports = { runIntake };
diff --git a/src/stages/iterate.js b/src/stages/iterate.js
new file mode 100644
index 0000000..e51c0a9
--- /dev/null
+++ b/src/stages/iterate.js
@@ -0,0 +1,40 @@
+// Stage 7 — iterate. qwen3:14b applies critic.fix_instructions to the HTML, single retry.
+
+const fs = require('node:fs/promises');
+const { ollama } = require('../llm');
+
+const SYSTEM = `You revise a single HTML+CSS document in place per the listed fixes.
+Output ONLY the revised raw HTML. No code fences, no prose. Same self-contained structure as before.`;
+
+async function runIterate({ htmlPath, html, spec, review, signal = null }) {
+ if (review.approved) return { changed: false, html };
+
+ const userPrompt = `Spec:
+${JSON.stringify(spec, null, 2)}
+
+Current HTML:
+---BEGIN HTML---
+${html}
+---END HTML---
+
+Issues found:
+${review.issues.map((s, i) => `${i + 1}. ${s}`).join('\n')}
+
+Fixes to apply:
+${review.fix_instructions.map((s, i) => `${i + 1}. ${s}`).join('\n')}
+
+Output the revised raw HTML now.`;
+
+ const revised = (await ollama({
+ model: process.env.COMPOSE_MODEL || 'qwen3:14b',
+ system: SYSTEM,
+ prompt: userPrompt,
+ temperature: 0.3,
+ signal
+ })).replace(/^```(?:html)?\s*\n/i, '').replace(/\n```\s*$/, '').trim();
+
+ await fs.writeFile(htmlPath, revised, 'utf8');
+ return { changed: true, html: revised };
+}
+
+module.exports = { runIterate };
diff --git a/src/stages/render.js b/src/stages/render.js
new file mode 100644
index 0000000..688dcc1
--- /dev/null
+++ b/src/stages/render.js
@@ -0,0 +1,37 @@
+// Stage 4 — render. Playwright headless Chromium → PNG screenshot at target dimensions.
+
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+async function runRender({ htmlPath, width, height, signal = null }) {
+ if (signal?.aborted) throw new Error('render aborted before launch');
+ const pngPath = htmlPath.replace(/\.html$/, '.png');
+ const browser = await chromium.launch({ headless: true });
+ // Wire external abort to browser.close() so a stage timeout actually kills
+ // the headless Chromium instead of leaving a zombie process eating GPU.
+ let onAbort = null;
+ if (signal) {
+ onAbort = () => { browser.close().catch(() => {}); };
+ signal.addEventListener('abort', onAbort, { once: true });
+ }
+ try {
+ const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2 });
+ const page = await ctx.newPage();
+ await page.goto(`file://${path.resolve(htmlPath)}`, { waitUntil: 'networkidle', timeout: 30000 });
+ // Allow Google Fonts to settle. Abortable so a cancelled stage doesn't
+ // sit here for the full 1.5s.
+ await Promise.race([
+ page.waitForTimeout(1500),
+ new Promise((_, rej) => signal?.addEventListener('abort', () => rej(new Error('render aborted during font-settle')), { once: true })),
+ ]);
+ await page.screenshot({ path: pngPath, fullPage: false, omitBackground: false, clip: { x: 0, y: 0, width, height } });
+ const stat = await fs.stat(pngPath);
+ return { pngPath, bytes: stat.size, width, height };
+ } finally {
+ if (signal && onAbort) signal.removeEventListener('abort', onAbort);
+ await browser.close().catch(() => {});
+ }
+}
+
+module.exports = { runRender };
diff --git a/src/stages/vision_check.js b/src/stages/vision_check.js
new file mode 100644
index 0000000..b3352fd
--- /dev/null
+++ b/src/stages/vision_check.js
@@ -0,0 +1,57 @@
+// Stage 5 — vision_check. llava describes what's actually on the rendered PNG.
+// This is the ground-truth visual signal that the critic uses to compare against the brief.
+
+const { ollama } = require('../llm');
+
+const SYSTEM = `You are a precise visual reviewer for a generated image.
+
+Output ONLY a JSON object:
+ text_content array of every word or phrase you can read (best effort — leave [] if you cannot read text reliably)
+ layout one short sentence on the composition
+ mood one short phrase
+ issues array — ONLY include CONCRETE visible problems: text clipping/overflow, lorem ipsum, broken/squished layout, all-blank canvas. Do NOT include subjective concerns. Empty array if the image looks intentional.
+ overall_quality integer 0-100 — your honest read of polish/intentionality. A clean editorial layout with clear hierarchy is 85+. A broken/blank/cluttered layout is <50.
+
+JSON only — no prose, no fences, no explanations.`;
+
+async function runVisionCheck({ pngPath, signal = null }) {
+ // Vision is best-effort. llava sometimes degenerates (loops empty strings,
+ // fails JSON parse). If it crashes, return a neutral "vision unavailable"
+ // shape so the pipeline can still finish and the critic can decide based
+ // on the brief alone. The PNG already exists — vision is just a signal.
+ try {
+ const desc = await ollama({
+ model: process.env.VISION_MODEL || 'llava:latest',
+ system: SYSTEM,
+ prompt: 'Describe this image as JSON per the system instructions.',
+ images: [pngPath],
+ format: 'json',
+ temperature: 0.1,
+ signal
+ });
+ return {
+ text_content: Array.isArray(desc.text_content) ? desc.text_content.filter(s => s && s.trim()).slice(0, 30) : [],
+ layout: String(desc.layout || ''),
+ mood: String(desc.mood || ''),
+ issues: Array.isArray(desc.issues) ? desc.issues.filter(s => s && s.trim()) : [],
+ overall_quality: Number(desc.overall_quality) || 0,
+ _vision_ok: true
+ };
+ } catch (err) {
+ // Don't swallow stage cancellation as "vision unavailable" — propagate so
+ // the pipeline correctly reports the run as timed out instead of silently
+ // moving on with a neutral score.
+ if (signal?.aborted || /aborted/i.test(err.message)) throw err;
+ return {
+ text_content: [],
+ layout: '',
+ mood: '',
+ issues: [],
+ overall_quality: 75, // neutral default — assume rendered OK; critic can override on other signals
+ _vision_ok: false,
+ _vision_error: err.message.slice(0, 200)
+ };
+ }
+}
+
+module.exports = { runVisionCheck };
diff --git a/viewer.js b/viewer.js
new file mode 100644
index 0000000..50b3cd8
--- /dev/null
+++ b/viewer.js
@@ -0,0 +1,12 @@
+require('dotenv').config();
+const express = require('express');
+const path = require('node:path');
+
+const app = express();
+const PORT = Number(process.env.VIEWER_PORT || 9893);
+
+app.use(express.static(path.join(__dirname, 'public')));
+
+app.listen(PORT, '127.0.0.1', () => {
+ console.log(`[visual-factory] viewer listening on http://127.0.0.1:${PORT}`);
+});
(oldest)
·
back to Visual Factory
·
apply codex p2/p2/p3 abort-handling fixes 287ac34 →