diff --git a/.github/workflows/auto-company-runtime-ci.yml b/.github/workflows/auto-company-runtime-ci.yml index 8cb33ef4..4d927e6a 100644 --- a/.github/workflows/auto-company-runtime-ci.yml +++ b/.github/workflows/auto-company-runtime-ci.yml @@ -184,6 +184,10 @@ jobs: run: python3 scripts/ci/run.py shell-contracts-5 bash tests/test_auto_loop_integration.sh - name: Verify real frontend script against deterministic API fixtures run: python3 scripts/ci/run.py shell-contracts-6 node tests/test_dashboard_frontend.js + - name: Verify published example logic and syntax + run: | + python3 scripts/ci/run.py showcase-core node --test projects/scopefence/tests/scopefence-core.test.mjs projects/text-meter/tests/counts.test.js + python3 scripts/ci/run.py showcase-syntax node --check projects/scope-sheet/app.js - name: Save failure evidence if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -209,11 +213,17 @@ jobs: package-manager-cache: false - name: Install browser test dependencies run: python3 scripts/ci/run.py browser-dependencies npm ci --prefix tests/browser --ignore-scripts --no-audit --no-fund + - name: Install product screenshot dependencies + run: python3 scripts/ci/run.py media-dependencies npm ci --prefix scripts/media --ignore-scripts --no-audit --no-fund - name: Install Chromium working-directory: tests/browser run: python3 ../../scripts/ci/run.py chromium-install npx playwright install --with-deps chromium - name: Test Dashboard with a real browser and isolated server run: python3 scripts/ci/run.py dashboard-browser npm test --prefix tests/browser + - name: Verify automatic product screenshots and process cleanup + env: + AUTO_COMPANY_TEST_PRODUCT_MEDIA_BROWSER: '1' + run: python3 scripts/ci/run.py product-media python3 -m unittest discover -s tests -p test_product_media.py -v - name: Save failure evidence if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.gitignore b/.gitignore index 1696ddc7..ba2f38f9 100644 --- a/.gitignore +++ b/.gitignore @@ -140,6 +140,7 @@ logs/ .auto-loop-budget-paused.* .auto-loop.env .auto-company.local +.auto-company/ .auto-company.local.* .auto-company-migrations/ .auto-loop-awake.pid @@ -160,6 +161,9 @@ projects/* # Explicitly published product examples; autonomous output stays ignored. !projects/tabledelta/ !projects/cuecheck/ +!projects/scopefence/ +!projects/text-meter/ +!projects/scope-sheet/ # Local/private outputs (do not publish) AGENTS.md @@ -212,6 +216,8 @@ docs/*/* !docs/windows-setup.md !docs/usage-governance.md !docs/runtime-observability.md +!docs/product-media.md +!docs/product-cycles.md # Ignore all memories (keep folder marker only) memories/* !memories/.gitkeep diff --git a/PROMPT.md b/PROMPT.md index d7471f63..0b9fa199 100644 --- a/PROMPT.md +++ b/PROMPT.md @@ -66,6 +66,8 @@ ## 收敛规则(强制) +下列 Cycle 1/2/3 是**新探索任务**的收敛顺序,不是每次进程启动后的业务重置。已有产品或已记录的探索任务必须接续共识中的实际进展;程序提供的产品轮次是累计工作记录,不代表业务阶段,也不规定交付必须经历多少轮。停止、重启或切换模型后,不因本次启动计数为 1 而重新选题。 + 1. **Cycle 1**:Brainstorm,每个 agent 提一个想法,结束时排出 top 3 2. **Cycle 2**:选 #1,critic-munger 做 Pre-Mortem,research-thompson 验证市场,cfo-campbell 算账。给出 GO / NO-GO 3. **Cycle 3+**:GO → 建 repo 开始写代码,禁止继续讨论。NO-GO → 试 #2,全不行就强选一个做 @@ -73,6 +75,11 @@ 5. **同一个 Next Action 连续出现 2 轮** → 卡住了,换方向或缩范围直接 ship 6. **凡是前端交付**(页面、界面、组件、dashboard、marketing site)→ 必须先使用 `frontend-design.md`,确保视觉与交互质量,不允许用通用默认风格直接输出 +## 产品页面与识别素材 + +- 前端产品优先复用已有的有效 SVG 图标;没有时提供一个简洁的 `icon.svg`,使用 `viewBox`、少量颜色和基础几何形状。产品页标识与 favicon 引用 Web 根目录的 `auto-company-icon.svg`:这是程序在交付或轮次结束时写入的已验证图标,勿手动创建或修改。不要使用脚本、外部资源、嵌入 HTML 或复杂滤镜,不调用专用生图模型。程序会校验输入图标,缺失或无效时使用默认图标;图标问题不阻断功能交付。 +- 程序会在轮次收尾与交付文档登记后截取实际页面。根目录 `index.html` 的静态 Web 产品无需另写截图命令;其他受支持入口按 `docs/product-media.md` 声明固定预览配置及必要的展示步骤。非界面产品声明不适用,不编造页面。截图失败不代表功能检查失败,也不能用概念图代替实拍。 + ## 人工治理与项目边界(强制) 1. `Human Overrides` 是人类专属区,必须逐字保留;任何改动都会触发整轮共识回滚并暂停循环。 diff --git a/README-ZH.md b/README-ZH.md index 1916c0e0..2e5b2768 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -23,9 +23,24 @@ ## 看板预览 -![Auto Company 看板](presentation/dashboard-showcase.png) +[![Auto Company Dashboard](presentation/dashboard-showcase.png)](presentation/dashboard-showcase.png) -新版看板用纵向时间轴串联各轮次,展示简洁工作记录、检查结果、交付成果、用量与日志。截图使用 Outline Desk 连续三轮的真实记录,以只读归档模式展示。标题和摘要来自模型按固定格式填写的工作记录;运行事实和已接入检查的结果由程序记录。缺失或过期的信息明确标注,已记录用量不等于完整账单。采集约定与支持范围见[运行记录说明](docs/runtime-observability.md)。 +ScopeFence 的真实四轮产品记录:04 展开,03、02、01 逐项收起且全部可见;立项前探索单独保留。看板展示工作汇报、检查、文档、实拍、用量与日志。标题和摘要仍是模型填写的记录,运行事实与支持的检查结果由程序采集;缺失、失败、过期和部分用量明确标注。支持范围见[运行记录](docs/runtime-observability.md)、[连续轮次](docs/product-cycles.md)和[自动实拍](docs/product-media.md)说明。 + + + + + + + + + + + + + + +
Text Meter · English范围确认单 / Scope Sheet · 中文
Text Meter · English Dashboard范围确认单 / Scope Sheet · 中文 Dashboard
两次启动的两个产品轮次,编号从 01 延续到 02。四个产品尝试;两次模型容量错误保留为失败,后续成功记录照常呈现。
## 这是什么? @@ -49,28 +64,23 @@ daemon (launchd / systemd --user, 崩溃自重启) ## 运行产物示例 -以下是 Auto-Company 自主运行生成的独立应用。截图展示的是产出应用,不是 Auto-Company 本身或其控制台。 - -人类仅下达启动指令和交付范围;从产品立项、方案讨论、设计开发到测试交付,均由 Agent 团队自主讨论、决策并执行,过程中无需人工介入。 +以下三个独立产品来自实际运行,中英文 README 展示同一套项目。ScopeFence 与范围确认单由默认流程自主选题;Text Meter 来自一个普通的文本统计需求。人类设置了运行权限、语言与停止边界,发布前进行了定向审查和必要修正,具体来源记录在各项目中。产品截图来自公开源码的实际默认界面;上方 Dashboard 保留原始运行事实,未修改失败或工作汇报。 - - + + + - - + + + - - + + +
行间 / TableDelta幕检 / CueCheckScopeFence · EnglishText Meter · English范围确认单 / Scope Sheet · 中文
行间 / TableDelta:CSV 差异核对结果幕检 / CueCheck:字幕检查与编辑工作台ScopeFenceText Meter范围确认单 / Scope Sheet
-

对比两份 CSV,查看新增、删除和修改,并导出变化报告。

-

查看源码 →

-
-

检查 SRT 字幕的时码、重叠与阅读速度,支持逐条编辑、重检和导出。

-

查看源码 →

-

将范围变更整理成无需登录的确认链接;返回链接是可编辑的沟通副本,不是经过验证的批准记录。

查看源码 →

在浏览器本地即时统计字符、非空白字符、以空白分隔的词与行数。

查看源码 →

填写合作范围、修改、交付与报价,检查必填项后复制或下载中文确认单。

查看源码 →

diff --git a/README.md b/README.md index 355e662c..327f4b65 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,24 @@ Optional Cursor and OpenAI-compatible adapters require explicit configuration. S ## Dashboard Preview -![Auto Company Dashboard](presentation/dashboard-showcase.png) +[![Auto Company Dashboard](presentation/dashboard-showcase.png)](presentation/dashboard-showcase.png) -The dashboard connects cycles in a vertical timeline, with concise work records, check results, deliverables, usage, and logs. This screenshot shows three real Outline Desk cycles in read-only archive mode. Titles and summaries are structured model reports; runtime facts and supported check results are recorded by the program. Missing or expired evidence stays explicit, and recorded usage is not a complete bill. See the [collection contract and supported tools](docs/runtime-observability.md). +Four real ScopeFence product cycles: 04 is expanded; 03, 02 and 01 remain individually collapsed and visible. Pre-product exploration is kept separately. The journal connects reports, checks, documents, real previews, usage and logs. Titles and summaries remain model-authored reports; runtime facts and supported check results are collected by the program. Missing, failed, stale and partial evidence stays explicit. See the [recording contract](docs/runtime-observability.md), [continuous cycles](docs/product-cycles.md) and [automatic previews](docs/product-media.md). + + + + + + + + + + + + + + +
Text Meter · English范围确认单 / Scope Sheet · 中文
Text Meter · English Dashboard范围确认单 / Scope Sheet · 中文 Dashboard
Two product cycles across two separate starts; numbering continues from 01 to 02.Four product attempts. Two provider-capacity failures remain visible alongside the successful later work.
## What Is This? @@ -49,28 +64,23 @@ Each cycle is an independent CLI call. `memories/consensus.md` is the only cross ## Generated Applications -These independent applications were created through autonomous Auto-Company runs. The screenshots show the resulting products, not Auto-Company itself or its dashboard. - -Humans supplied only the instruction to start and the delivery scope. From product selection and planning through design, development, testing, and delivery, the Agent team discussed, decided, and executed autonomously, with no human intervention in that process. +These three local products come from actual runs and appear in both README languages. ScopeFence and Scope Sheet chose their directions through the default workflow; Text Meter came from an ordinary text-counting request. Humans set permissions, language and external run boundaries, then reviewed and made necessary publication fixes documented in each project. Product images show the actual default interfaces of the published source; the Dashboards above retain the original reports and failures. - - + + + - - + + + - - + + +
行间 / TableDelta幕检 / CueCheckScopeFence · EnglishText Meter · English范围确认单 / Scope Sheet · 中文
TableDelta: CSV comparison resultsCueCheck: subtitle checking and editing workspaceScopeFenceText Meter范围确认单 / Scope Sheet
-

Compare two CSV files, inspect added, removed, and changed records, and export a change report.

-

View source →

-
-

Check SRT subtitle timing, overlaps, and reading speed; edit individual subtitles, recheck, and export.

-

View source →

-

Turn a scope change into a no-login decision link. The returned link is an editable communication copy, not verified approval.

View source →

Count characters, non-whitespace characters, whitespace-separated words and lines locally as you type.

View source →

Prepare a Chinese scope note, check required fields, and copy or download the text for a client conversation.

View source →

diff --git a/dashboard/app.js b/dashboard/app.js index 67f26431..d2241715 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -27,6 +27,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. // Lucide 0.468.0, pinned f12b0de; ISC/MIT notices in LICENSE-lucide.txt. const ICONS = {"notebook-pen": "\n \n \n \n \n ", "chart-no-axes-column": "\n \n ", "terminal": "\n ", "play": "", "square": "", "sliders-horizontal": "\n \n \n \n \n \n \n \n ", "refresh-cw": "\n \n \n ", "file-text": "\n \n \n \n ", "list-checks": "\n \n \n \n ", "panels-top-left": "\n \n ", "external-link": "\n \n ", "chevron-right": "", "x": "\n "}; const $ = (id) => document.getElementById(id); + const DEFAULT_HISTORY_LIMIT = 4; const state = { data: null, language: 'zh-CN', tab: 'work', expanded: new Set(), older: false, selectedLog: 'runtime', logText: '', logLoadedId: '', logRequest: 0, logPending: null, refreshPending: null, signature: '', statusFailed: true, action: '', languageState: null, languageSaving: false, languageLoading: false, languageRevision: 0, languageError: '', languageSaved: false, timer: null, autoChanged: false, currentCycle: null, receivedAt: 0, elapsedTimer: null }; const message = (key, values = {}) => { const dictionary = window.JOURNAL_MESSAGES[state.language] || window.JOURNAL_MESSAGES.en; @@ -47,6 +48,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. return text.length > length ? `${text.slice(0, length).trim()}…` : text; } function statusLabel(status) { + if (['not_started', 'startup_unconfirmed'].includes(status)) return message(status); return ['stopping', 'stop_failed', 'completed', 'completed_with_timeout', 'failed', 'interrupted', 'stopped_status', 'running', 'idle', 'paused', 'waiting_limit', 'circuit_break', 'stopped', 'active', 'inactive', 'configured', 'not_configured', 'not_installed', 'mismatched', 'activating', 'deactivating', 'reloading', 'unsupported'].includes(status) ? message(status) : status === 'unavailable' ? message('statusUnavailable') : message('unknown'); } function readOnly() { return state.data?.readOnly !== false; } @@ -94,6 +96,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. } function cycleTitle(cycle) { if (cycle.workReport) return cycle.workReport.title; + if (['not_started', 'startup_unconfirmed'].includes(cycle.status)) return statusLabel(cycle.status); if (cycle.status === 'interrupted' && cycle.events?.length) return message('interruptedSummary'); if (cycle.synthetic && cycle.status !== 'running') return statusLabel(cycle.status); let title = clean(cycle.summary || cycle.report || '').split('\n').find((line) => line.trim()) || ''; @@ -106,9 +109,11 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. } function metadata(cycle) { const row = element('div', 'cycle-meta'); + if (cycle.numbering === 'legacy') row.append(element('span', '', message('legacyCycles'))); + if (cycle.identityKind === 'exploration') row.append(element('span', '', message('explorationCycles'))); if (cycle.workReport?.phase) row.append(element('span', 'reported-phase', message(`workPhase_${cycle.workReport.phase}`))); if (['failed', 'interrupted', 'unknown', 'completed_with_timeout'].includes(cycle.status)) row.append(element('span', cycle.status === 'failed' ? 'status-failed' : '', statusLabel(cycle.status))); - row.append(element('span', '', message('startAt', { time: formatTime(cycle.startedAt) }))); + row.append(element('span', '', message(cycle.reservedAt && !cycle.startedAt ? 'reservedAt' : 'startAt', { time: formatTime(cycle.startedAt || cycle.reservedAt) }))); const elapsed = duration(cycle); if (elapsed) row.append(element('span', '', elapsed)); if (cycle.active && cycle.status === 'running') { @@ -127,6 +132,17 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. }); return details; } + function paddedCycleNumber(cycle) { return String(cycle.number ?? '—').padStart(2, '0'); } + function historyGroups(data, current) { + const cycles = Array.isArray(data?.cycles) ? data.cycles : []; + const hasProduct = cycles.some((cycle) => cycle.identityKind === 'product'); + const separateExploration = hasProduct; + const older = cycles.filter((cycle) => cycle.id !== current?.id); + return { + main: older.filter((cycle) => !separateExploration || cycle.identityKind !== 'exploration'), + exploration: separateExploration ? older.filter((cycle) => cycle.identityKind === 'exploration') : [], + }; + } function logButton(cycle) { if (cycle.synthetic) { const button = element('button', 'text-button cycle-log-link', message('viewRuntimeLog')); @@ -294,7 +310,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. article.setAttribute('aria-current', 'step'); article.setAttribute('aria-labelledby', 'cycleTitle'); const gutter = element('div', 'cycle-gutter'); - const cycleNumber = element('span', 'cycle-number', String(cycle.number ?? '—').padStart(2, '0')); + const cycleNumber = element('span', 'cycle-number', paddedCycleNumber(cycle)); cycleNumber.id = 'cycleNumber'; gutter.append(cycleNumber); const body = element('div', 'cycle-body'); @@ -332,38 +348,67 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. article.append(gutter, progressIcon(cycle.status), body); container.append(article); } + function historyRow(cycle, exploration = false) { + const row = bindDisclosure(element('details', `history-row${exploration ? ' exploration-row' : ''}`), `cycle:${cycle.id}`); + row.dataset.cycleId = cycle.id; + const summary = element('summary'); + const association = cycle.projectStatus === 'other' ? message('otherProjectCycle') : cycle.projectStatus === 'unknown' ? message('unknownProjectCycle') : cycle.identityKind === 'exploration' ? message('explorationCycles') : ''; + const timing = `${statusLabel(cycle.status)} · ${formatTime(cycle.startedAt)}${association ? ` · ${association}` : ''}`; + const cycleNumber = exploration ? message('explorationNumber', { number: paddedCycleNumber(cycle) }) : paddedCycleNumber(cycle); + summary.append(element('span', 'history-number', cycleNumber), progressIcon(cycle.status), element('span', 'history-title', cycleTitle(cycle)), element('span', `history-meta${cycle.status === 'failed' ? ' status-failed' : ''}`, timing)); + const arrow = icon('chevron-right'); arrow.classList.add('history-chevron'); + arrow.setAttribute('aria-hidden', 'true'); + summary.append(arrow); + const content = element('div', 'history-content'); + content.append(metadata(cycle)); + let report = cycle.workReport?.summary || clean(cycle.summary || ''); + if (/^[\[{]/.test(report)) report = cycleTitle(cycle); + content.append(element('p', '', report || message('noSummary'))); + const workDetails = workReportDetails(cycle); + if (workDetails) content.append(workDetails); + const results = cycle.workReport ? [] : reportRows(cycle); + if (results.length) content.append(resultList(results)); + content.append(cycleRecords(cycle)); + content.append(logButton(cycle)); + row.append(summary, content); + return row; + } + function renderExplorationHistory(cycles) { + const section = clear($('explorationSection')); + section.hidden = !cycles.length; + if (!cycles.length) return; + const disclosure = bindDisclosure(element('details', 'exploration-disclosure'), 'exploration-records'); + const summary = element('summary'); + const label = element('span', 'exploration-heading', message('explorationRecords')); + label.id = 'explorationHeading'; + summary.append(label, element('span', 'exploration-note', message('explorationRecordsNote', { count: cycles.length }))); + const arrow = icon('chevron-right'); arrow.classList.add('exploration-chevron'); + summary.append(arrow); + disclosure.setAttribute('aria-labelledby', label.id); + const list = element('div', 'exploration-list'); + for (const cycle of cycles) list.append(historyRow(cycle, true)); + disclosure.append(summary, list); + section.append(disclosure); + } function renderHistory() { const history = clear($('historyList')); - const older = state.data.cycles.filter((cycle) => cycle.id !== state.currentCycle?.id); - const visible = state.older ? older : older.slice(0, 2); + const groups = historyGroups(state.data, state.currentCycle); + const older = groups.main; + older.sort((a, b) => Number(a.numbering === 'legacy') - Number(b.numbering === 'legacy')); + const visible = state.older ? older : older.slice(0, DEFAULT_HISTORY_LIMIT); + $('historyNote').textContent = message(state.data.cycleNumbering?.mode === 'persistent' ? (state.data.cycleNumbering.hasLegacy ? 'mixedHistoryNote' : 'persistentHistoryNote') : state.data.cycleNumbering?.mode === 'unavailable' ? 'numberingUnavailable' : 'historyNote'); + let group = null; for (const cycle of visible) { - const row = bindDisclosure(element('details', 'history-row'), `cycle:${cycle.id}`); - row.dataset.cycleId = cycle.id; - const summary = element('summary'); - const association = cycle.projectStatus === 'other' ? message('otherProjectCycle') : cycle.projectStatus === 'unknown' ? message('unknownProjectCycle') : ''; - const timing = `${statusLabel(cycle.status)} · ${formatTime(cycle.startedAt)}${association ? ` · ${association}` : ''}`; - summary.append(element('span', 'history-number', String(cycle.number ?? '—').padStart(2, '0')), progressIcon(cycle.status), element('span', 'history-title', cycleTitle(cycle)), element('span', `history-meta${cycle.status === 'failed' ? ' status-failed' : ''}`, timing)); - const arrow = icon('chevron-right'); arrow.classList.add('history-chevron'); - arrow.setAttribute('aria-hidden', 'true'); - summary.append(arrow); - const content = element('div', 'history-content'); - content.append(metadata(cycle)); - let report = cycle.workReport?.summary || clean(cycle.summary || ''); - if (/^[\[{]/.test(report)) report = cycleTitle(cycle); - content.append(element('p', '', report || message('noSummary'))); - const workDetails = workReportDetails(cycle); - if (workDetails) content.append(workDetails); - const results = cycle.workReport ? [] : reportRows(cycle); - if (results.length) content.append(resultList(results)); - content.append(cycleRecords(cycle)); - content.append(logButton(cycle)); - row.append(summary, content); - history.append(row); + const nextGroup = cycle.numbering === 'legacy' ? 'legacy' : 'persistent'; + if (nextGroup !== group && nextGroup === 'legacy' && state.data.cycleNumbering?.mode === 'persistent') history.append(element('h3', 'history-group-label', message('legacyCycles'))); + group = nextGroup; + history.append(historyRow(cycle)); } - $('olderButton').hidden = older.length <= 2; - $('olderButton').textContent = state.older ? message('fewer') : message('older', { count: older.length - 2 }); + $('olderButton').hidden = older.length <= DEFAULT_HISTORY_LIMIT; + $('olderButton').textContent = state.older ? message('fewer') : message('older', { count: older.length - DEFAULT_HISTORY_LIMIT }); document.querySelector('.history-section').hidden = !older.length; document.querySelector('.journal-layout').classList.toggle('no-history', !older.length); + renderExplorationHistory(groups.exploration); } function aggregate(cycles) { const result = { inputTokens: null, outputTokens: null, totalTokens: null, known: 0, count: cycles.length, partial: false }; @@ -405,7 +450,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. const process = runtime.processState || runtime.state; const action = state.action || data?.control?.action; const retryStop = data?.control?.stopUnconfirmed === true; - const locked = !data || readOnly() || (unavailable && !retryStop) || Boolean(action); + const locked = !data || readOnly() || (unavailable && !retryStop) || Boolean(action || state.mediaAction); $('runtimeState').textContent = runtimeLabel(); $('runtimeState').dataset.state = unavailable ? 'unavailable' : runtime.state || 'unknown'; $('startButton').disabled = locked || retryStop || !['stopped', 'inactive'].includes(process); @@ -500,6 +545,97 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. } return message(artifact.evidenceStatus === 'stale' ? 'artifactStale' : 'artifactUnavailable'); } + function mediaURL(value, productId) { + if (typeof productId !== 'string' || !/^[0-9a-f]{32}$/.test(productId) || typeof value !== 'string') return null; + return new RegExp(`^/api/product-media/${productId}/[a-zA-Z0-9_.-]+\\.(?:png|svg)$`).test(value) ? value : null; + } + async function retryProductMedia(productId) { + if (readOnly() || state.mediaAction || productId !== state.data?.productMedia?.productId) return; + const previousCapture = state.data?.productMedia?.screenshot; + const previousSuccess = previousCapture?.latestSuccess?.capturedAt || null; + state.mediaAction = true; state.mediaError = null; + renderSidebar(); + renderRuntime(); + try { + await fetchJSON('/api/product-media/capture', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productId }) }, 170000); + } catch (_) { state.mediaError = { productId, previousSuccess }; } + finally { + state.mediaAction = false; + await refresh(); + renderSidebar(); + renderRuntime(); + } + } + function mediaRetryError(media) { + const error = state.mediaError; + if (!error) return false; + const capture = media?.screenshot; + if (error.productId !== media?.productId || (capture?.state === 'success' && capture.latestSuccess?.capturedAt !== error.previousSuccess)) { + state.mediaError = null; + return false; + } + return true; + } + function iconPublicationWarning(icon) { + if (icon?.publicationStatus === 'conflict') return 'iconPublicationConflict'; + if (icon?.publicationStatus === 'failed') return 'iconPublicationFailed'; + if (['stale', 'unavailable'].includes(icon?.publicationStatus)) return 'iconPublicationChanged'; + const reference = icon?.reference; + if (reference?.state === 'preserved') return 'iconReferencePreserved'; + return reference && !['inserted', 'linked', 'not_applicable'].includes(reference.state) ? 'iconReferenceUnconfirmed' : null; + } + function renderProductMedia(media) { + if (!media?.productId) return null; + const section = element('section', 'sidebar-block product-media'); + section.append(element('h2', '', message('productScreenshot'))); + const capture = media.screenshot || {}; + const success = capture.latestSuccess; + const variants = (success?.variants || []).filter((item) => mediaURL(item.href, media.productId)); + const desktop = variants.find((item) => item.viewport === 'desktop') || variants[0]; + if (desktop) { + const link = element('a', 'product-screenshot-link'); + link.href = mediaURL(desktop.href, media.productId); + link.target = '_blank'; link.rel = 'noopener'; + link.setAttribute('aria-label', message('openScreenshot')); + const image = element('img', 'product-screenshot'); + image.src = link.href; + image.alt = message('screenshotAlt', { name: state.data.project?.name || message('noProject') }); + image.loading = 'lazy'; image.decoding = 'async'; + if (Number.isInteger(desktop.width) && Number.isInteger(desktop.height) && desktop.width > 0 && desktop.height > 0) { + image.width = desktop.width; image.height = desktop.height; + } + image.addEventListener('error', () => { + link.replaceWith(element('p', 'sidebar-note status-failed', message('screenshotResourceUnavailable'))); + }, { once: true }); + link.append(image); section.append(link); + const caption = element('p', 'sidebar-note screenshot-caption', message('capturedAt', { time: formatTime(success.capturedAt, true) })); + if (capture.currentVersion && success.version !== capture.currentVersion) caption.append(element('span', 'screenshot-stale', message('screenshotOldVersion'))); + section.append(caption); + const links = element('div', 'screenshot-links'); + for (const variant of variants) { + const item = element('a', '', message(variant.viewport === 'mobile' ? 'mobileScreenshot' : 'desktopScreenshot')); + item.href = mediaURL(variant.href, media.productId); item.target = '_blank'; item.rel = 'noopener'; + links.append(item); + } + section.append(links); + } + if (!desktop || !['completed', 'ready', 'success', 'unchanged'].includes(capture.state)) { + const key = { capturing: 'screenshotCapturing', running: 'screenshotCapturing', pending: 'screenshotCapturing', + failed: 'screenshotFailed', interrupted: 'screenshotInterrupted', unsupported: 'screenshotUnsupported', + not_applicable: 'screenshotNotApplicable', unavailable: 'screenshotUnavailable', stale: 'screenshotOldVersion' }[capture.state] || 'screenshotMissing'; + section.append(element('p', 'sidebar-note', message(key))); + } + if (!readOnly()) { + const retry = element('button', 'text-button screenshot-retry', message(state.mediaAction ? 'screenshotCapturing' : 'retryScreenshot')); + retry.id = 'retryScreenshot'; + retry.disabled = Boolean(state.mediaAction || state.statusFailed || state.action || state.data?.control?.action || state.data?.control?.stopUnconfirmed || !['stopped', 'inactive'].includes(state.data?.runtime?.processState)); + retry.title = message('retryScreenshotHint'); + retry.addEventListener('click', () => retryProductMedia(media.productId)); + section.append(retry); + if (mediaRetryError(media)) section.append(element('p', 'sidebar-note status-failed', message('screenshotRetryFailed'))); + } + return section; + } function renderSidebar() { const sidebar = clear($('projectSidebar')); const data = state.data; @@ -508,7 +644,20 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. const description = element('p', 'project-description', clean(data.project?.description)); description.id = 'projectDescription'; description.title = description.textContent; const latest = state.currentCycle; const date = element('p', 'sidebar-note', latest?.active ? message('currentRun') : latest ? message('latestRun', { date: formatDate(latest.startedAt) }) : message(readOnly() ? 'archived' : 'ready')); date.id = 'runHeading'; - overview.append(name, description, date); + const identity = element('div', 'project-identity'); + const media = data.productMedia; + const productIcon = mediaURL(media?.icon?.href, media?.productId); + if (productIcon) { + const image = element('img', 'product-icon'); + image.src = productIcon; image.alt = ''; image.width = image.height = 36; + image.title = message(media.icon.source === 'default' ? 'defaultProductIcon' : 'productIcon'); + image.addEventListener('error', () => image.remove(), { once: true }); + identity.append(image); + } + identity.append(name); + overview.append(identity, description, date); + const iconWarning = iconPublicationWarning(media?.icon); + if (iconWarning) overview.append(element('p', 'sidebar-note status-failed', message(iconWarning))); const artifacts = element('section', 'sidebar-block'); artifacts.append(element('h2', '', message('artifacts'))); const visibleArtifacts = (data.artifacts || []).filter((artifact) => artifact.kind !== 'check'); @@ -546,7 +695,10 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. details.append(element('summary', '', message('moreRuntime')), runtimeRows([[message('state'), runtimeLabel()], [message('source'), data.sourceName]])); details.append(element('p', 'sidebar-note', message(config.configSource === 'session_context' ? 'observedSession' : 'unconfirmedSession'))); runtime.append(details); - sidebar.append(overview, artifacts, runtime); + sidebar.append(overview); + const productMedia = renderProductMedia(media); + if (productMedia) sidebar.append(productMedia); + sidebar.append(artifacts, runtime); } function applyLanguage() { document.documentElement.lang = state.language; @@ -650,7 +802,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. else if (total.partial) summary.append(element('p', 'usage-warning', message('partial'))); for (const cycle of cycles) { const row = element('tr'); - row.append(element('td', '', `Cycle ${String(cycle.number ?? '—').padStart(2, '0')}`), element('td', '', formatTime(cycle.startedAt, true)), element('td', '', statusLabel(cycle.status))); + row.append(element('td', '', cycle.identityKind === 'exploration' ? message('explorationNumber', { number: paddedCycleNumber(cycle) }) : `${message('cycle')} ${paddedCycleNumber(cycle)}`), element('td', '', formatTime(cycle.startedAt, true)), element('td', '', statusLabel(cycle.status))); for (const field of ['inputTokens', 'outputTokens', 'totalTokens']) { const cell = element('td', 'numeric', number(cycle.usage?.[field])); if (!knownNumber(cycle.usage?.[field])) cell.title = message('unknown'); @@ -689,7 +841,8 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. global.value = 'runtime'; select.append(global); for (const cycle of cycles) { - const option = element('option', '', `Cycle ${String(cycle.number ?? '—').padStart(2, '0')} · ${formatTime(cycle.startedAt, true)} · ${statusLabel(cycle.status)}`); + const label = cycle.identityKind === 'exploration' ? message('explorationNumber', { number: paddedCycleNumber(cycle) }) : `${message('cycle')} ${paddedCycleNumber(cycle)}`; + const option = element('option', '', `${label} · ${formatTime(cycle.startedAt, true)} · ${statusLabel(cycle.status)}`); option.value = cycle.id; select.append(option); } diff --git a/dashboard/i18n.js b/dashboard/i18n.js index 71415dab..342dae24 100644 --- a/dashboard/i18n.js +++ b/dashboard/i18n.js @@ -1,6 +1,31 @@ /* The journal follows the shared preference and the current product language lock. */ window.JOURNAL_MESSAGES = { "zh-CN": { + "productScreenshot": "产品实拍", + "openScreenshot": "查看产品截图原图", + "screenshotAlt": "{name} 的实际页面截图", + "capturedAt": "截取于 {time}", + "desktopScreenshot": "桌面原图 ↗", + "mobileScreenshot": "手机原图 ↗", + "screenshotOldVersion": "来自上一版本", + "screenshotCapturing": "正在截取页面", + "screenshotFailed": "本次截图失败", + "screenshotInterrupted": "截图中断,等待重新采集", + "screenshotUnsupported": "当前预览方式暂不支持自动截图", + "screenshotNotApplicable": "此产品无需页面截图", + "screenshotUnavailable": "截图工具暂不可用", + "screenshotMissing": "尚无产品截图", + "screenshotResourceUnavailable": "截图文件当前不可用", + "defaultProductIcon": "默认产品图标", + "productIcon": "产品图标", + "retryScreenshot": "重新截图", + "retryScreenshotHint": "空闲时重新截取当前产品,不启动 AI 工作", + "screenshotRetryFailed": "重新截图未完成,请查看采集状态或稍后重试", + "iconPublicationChanged": "网页图标已变化或不可用;此处显示已保存的验证图标", + "iconReferencePreserved": "产品网页保留原有图标;此处显示已登记图标", + "iconReferenceUnconfirmed": "尚未确认产品网页使用此图标", + "iconPublicationConflict": "网页图标存在冲突,已保留原文件;此处显示已验证图标", + "iconPublicationFailed": "图标尚未写入产品网页;此处显示已验证图标", "pendingCycle": "尚未开始", "checkUnconfirmed": "检查结果尚未确认", "cycleRecords": "本轮记录", @@ -59,7 +84,18 @@ window.JOURNAL_MESSAGES = { "readFailed": "记录读取失败。请检查 Dashboard 服务后重新刷新。启动和停止暂不可用。", "stale": "刷新失败,以下仍是上次读取的记录。当前运行状态不可确认,启动和停止暂不可用。", "history": "此前轮次", - "historyNote": "编号按每次启动重新计数", + "historyNote": "旧运行记录 · 保留原编号", + "persistentHistoryNote": "产品轮次连续累计", + "numberingUnavailable": "暂时无法确认轮次编号", + "mixedHistoryNote": "新记录连续计数 · 旧记录保留原编号", + "reservedAt": "登记于 {time}", + "legacyCycles": "旧运行记录", + "explorationCycles": "立项前探索", + "explorationRecords": "立项前探索记录", + "explorationRecordsNote": "{count} 条记录 · 保留原编号和状态", + "explorationNumber": "探索 {number}", + "not_started": "本轮尚未执行", + "startup_unconfirmed": "本轮启动未确认", "latestRun": "最近一次运行 · {date}", "latestCycle": "最近完成的轮次", "cycle": "轮次", @@ -295,6 +331,31 @@ window.JOURNAL_MESSAGES = { "previewName": "产品预览" }, "en": { + "productScreenshot": "Product screenshots", + "openScreenshot": "Open the original product screenshot", + "screenshotAlt": "Actual page captured from {name}", + "capturedAt": "Captured {time}", + "desktopScreenshot": "Desktop image ↗", + "mobileScreenshot": "Mobile image ↗", + "screenshotOldVersion": "From an earlier version", + "screenshotCapturing": "Capturing the page", + "screenshotFailed": "Latest capture failed", + "screenshotInterrupted": "Capture interrupted; awaiting retry", + "screenshotUnsupported": "Automatic capture is not supported for this preview", + "screenshotNotApplicable": "Page screenshots do not apply to this product", + "screenshotUnavailable": "Screenshot tools are unavailable", + "screenshotMissing": "No product screenshots yet", + "screenshotResourceUnavailable": "Screenshot file is unavailable", + "defaultProductIcon": "Default product icon", + "productIcon": "Product icon", + "retryScreenshot": "Capture again", + "retryScreenshotHint": "Capture the current product while idle, without starting AI work", + "screenshotRetryFailed": "Capture did not complete. Check the capture status or retry later.", + "iconPublicationChanged": "The published icon changed or is unavailable; showing the saved validated icon", + "iconReferencePreserved": "The product keeps its existing favicon; showing the registered icon here", + "iconReferenceUnconfirmed": "Use of this icon by the product page is unconfirmed", + "iconPublicationConflict": "Web icon conflict: the original file was preserved. Showing the validated icon here.", + "iconPublicationFailed": "The icon could not be published to the product page. Showing the validated icon here.", "pendingCycle": "Not started", "checkUnconfirmed": "Check result is not confirmed", "cycleRecords": "Cycle record", @@ -353,7 +414,18 @@ window.JOURNAL_MESSAGES = { "readFailed": "Could not read the records. Check the Dashboard service and refresh. Start and stop are unavailable.", "stale": "Refresh failed. The last retrieved records are still shown. The current runtime state is unconfirmed; start and stop are unavailable.", "history": "Earlier cycles", - "historyNote": "Cycle numbering restarts with each run", + "historyNote": "Legacy runs · original numbers retained", + "persistentHistoryNote": "Continuous product cycle numbers", + "numberingUnavailable": "Cycle numbering could not be confirmed", + "mixedHistoryNote": "New records continue counting · legacy numbers retained", + "reservedAt": "Registered {time}", + "legacyCycles": "Legacy runs", + "explorationCycles": "Pre-product exploration", + "explorationRecords": "Pre-product exploration records", + "explorationRecordsNote": "{count} records · original numbers and states retained", + "explorationNumber": "EXP {number}", + "not_started": "Cycle not started", + "startup_unconfirmed": "Cycle launch unconfirmed", "latestRun": "Latest run · {date}", "latestCycle": "Latest completed cycle", "cycle": "Cycle", diff --git a/dashboard/index.html b/dashboard/index.html index e178f06b..1664a2b3 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -57,11 +57,12 @@

此前轮次

- 编号按每次启动重新计数 +
+
diff --git a/dashboard/journal_data.py b/dashboard/journal_data.py index 3079dd7d..8890e923 100644 --- a/dashboard/journal_data.py +++ b/dashboard/journal_data.py @@ -1,6 +1,6 @@ """Bounded journal projections of an Auto Company run. -Cycle identities come only from the usage ledger. A consensus is the latest +Cycle identities come from program-owned ledgers. A consensus is the latest work report, not proof of a running process or a stream of agent activity. """ @@ -22,6 +22,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts" / "core")) from cycle_reports import read_report # noqa: E402 from project_metadata import read_metadata # noqa: E402 +from product_identity import continuation_project, cycle_projects, get_identity, list_cycle_projections # noqa: E402 +from product_media import media_projection, read_resource # noqa: E402 MAX_LEDGER_BYTES = 4 * 1024 * 1024 @@ -221,6 +223,11 @@ def documents(self, registered: list[dict[str, Any]] | None = None) -> list[dict def project(self) -> dict[str, Any]: """Return only selection-bound metadata; consensus is never identity.""" selected = self.pairs(".auto-company.local").get("ACTIVE_PROJECT", "") + if not selected: + try: + selected = continuation_project(self.root) + except (OSError, ValueError, TypeError): + selected = "" if not re.fullmatch(r"projects/[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", selected): return {"id": None, "name": "Auto Company", "displayName": "Auto Company", "description": "", "source": "workspace", "status": "unselected", @@ -241,6 +248,59 @@ def project(self) -> dict[str, Any]: "source": metadata["source"], "status": "recorded", "recordedAt": timestamp(metadata["recordedAt"])} + def media_resource(self, product_id: str, name: str) -> tuple[bytes, str]: + result = read_resource(self.root, product_id, name) + if result is None: + raise FileNotFoundError("Registered media resource is unavailable") + return result + + def persistent_cycles(self, cycles, project, warnings): + """Merge identity-owned attempts without rewriting old usage records.""" + try: + identity = get_identity(self.root, project["id"], create=False) if project["id"] else None + except (OSError, ValueError, TypeError, KeyError): + warnings.append("product_cycle_identity_unavailable") + identity = None + projection_available = True + try: + projections = list_cycle_projections(self.root, limit=MAX_CYCLES) + except (OSError, ValueError, TypeError, KeyError): + warnings.append("product_cycle_ledger_unavailable") + projection_available = False + projections = {"cycles": [], "total": 0} + project["stableId"] = identity["id"] if identity else None + rows = {row["cycleId"]: row for row in projections["cycles"]} + known = {cycle["id"] for cycle in cycles} + for row in rows.values(): + if row["cycleId"] in known: + continue + state = row["state"] + # Pending attempts are never promoted to live execution from intent. + status = {"reserved": "not_started", "dispatching": "startup_unconfirmed"}.get(state, state) + cycles.append({"id": row["cycleId"], "number": row["productCycleNumber"], + "startedAt": timestamp(row.get("startedAt")), "reservedAt": timestamp(row["reservedAt"]), + "endedAt": timestamp(row.get("endedAt")), "status": status, + "durationReliable": False, "endedAtKind": "identity_ledger", + "engine": row.get("engine") or "unknown", "model": row.get("model") or "unknown", + "summary": "", "report": "", "active": False, "logAvailable": False, + "projectId": row.get("project") or None, "costUsd": None, "costStatus": "unavailable", + "budget": None, "usage": {"inputTokens": None, "outputTokens": None, + "totalTokens": None, "status": "unavailable"}}) + for cycle in cycles: + row = rows.get(cycle["id"]) + cycle["runCycleNumber"] = cycle["number"] + cycle["numbering"] = "persistent" if row else "legacy" if projection_available else "unavailable" + if row: + cycle.update(number=row["productCycleNumber"], productCycleNumber=row["productCycleNumber"], runCycleNumber=row["runCycleNumber"], + stableProductId=row.get("productId"), identityKind=row["kind"], + linkedProductId=row.get("linkedProductId"), identityState=row["state"]) + cycles.sort(key=lambda cycle: (datetime.fromisoformat(cycle.get("startedAt") or cycle["reservedAt"]).timestamp() if cycle.get("startedAt") or cycle.get("reservedAt") else 0, cycle["id"]), reverse=True) + if projections.get("total", 0) > len(rows): + warnings.append("product_cycle_history_truncated") + return {"mode": "persistent" if identity else "legacy" if projection_available else "unavailable", "productId": project["stableId"], + "hasLegacy": any(cycle["numbering"] == "legacy" for cycle in cycles), + "total": projections.get("total", len(rows))} + def cycle_context(self, identity: str) -> dict[str, Any]: """Read one program-owned cycle/project association.""" try: @@ -285,7 +345,7 @@ def active_cycle(self, status: dict[str, Any]) -> dict[str, Any] | None: identity = pending.get("cycle_id") number = pending.get("cycle_number") started = timestamp(pending.get("started_at")) - selected = self.pairs(".auto-company.local").get("ACTIVE_PROJECT") + selected = self.project()["id"] context = self.cycle_context(identity) if isinstance(identity, str) and CYCLE_ID.fullmatch(identity) else {} if (type(pending.get("schema_version")) is not int or pending["schema_version"] != 1 or pending.get("kind") != "cycle_usage" or not isinstance(identity, str) @@ -344,9 +404,9 @@ def snapshot(self, *, status: dict[str, Any] | None = None, generated = datetime.now(timezone.utc) generated_at = generated.isoformat() records, warnings = self.ledger() - artifact_data = artifact_projection(self) - registered = artifact_data["items"] project = self.project() + artifact_data = artifact_projection(self, project["id"] or "") + registered = artifact_data["items"] raw = "" updated_at = None try: @@ -434,6 +494,11 @@ def snapshot(self, *, status: dict[str, Any] | None = None, warnings.append("active_cycle_unavailable") if not available: warnings.append("runtime_unavailable") + numbering = self.persistent_cycles(cycles, project, warnings) + if runtime["currentCycleId"]: + current = next((cycle for cycle in cycles if cycle["id"] == runtime["currentCycleId"]), None) + if current: + runtime["currentCycleNumber"] = current["number"] for cycle in cycles[:30]: cycle["detailStatus"] = "recorded" cycle.update(read_report(self, cycle)) @@ -466,6 +531,14 @@ def snapshot(self, *, status: dict[str, Any] | None = None, "recordedAt": bound[0].get("recordedAt"), "source": "runner"} else: cycle_project = None + if cycle.get("identityKind") == "exploration" and cycle.get("linkedProductId") == project["stableId"] and project["stableId"]: + try: + if selected_project in cycle_projects(self.root, cycle["id"]): + cycle_project = selected_project + context = {"project": selected_project, "status": "recorded", "recordedAt": None, + "source": "product_cycle_ledger", "kind": "linked_exploration"} + except (OSError, ValueError, TypeError, KeyError): + warnings.append("exploration_association_unavailable") if cycle_project and reported_project and reported_project != cycle_project: cycle["workReport"] = None cycle["workReportStatus"] = "identity_mismatch" @@ -473,6 +546,8 @@ def snapshot(self, *, status: dict[str, Any] | None = None, cycle["projectIdentity"] = context cycle["projectStatus"] = ("current" if cycle_project and cycle_project == selected_project else "other" if cycle_project else "unknown") + if cycle.get("stableProductId") and cycle["stableProductId"] != project["stableId"]: + cycle["projectStatus"] = "other" cycle["belongsToCurrentProject"] = cycle["projectStatus"] == "current" cycle_artifacts = [item for item in bound if cycle_project and item.get("project") == cycle_project] checks = [item for item in cycle_artifacts if item["kind"] == "check"] @@ -491,6 +566,8 @@ def snapshot(self, *, status: dict[str, Any] | None = None, "other" if recorded_project else "unknown", "artifacts": [], "checks": [], "latestCheck": None, "checkStatus": "unregistered"}) + if cycle.get("stableProductId") and cycle["stableProductId"] != project["stableId"]: + cycle["projectStatus"] = "other" cycle["belongsToCurrentProject"] = cycle["projectStatus"] == "current" if cycles: observed_cycle = next((cycle for cycle in cycles if cycle.get("projectStatus") == "current"), None) @@ -505,11 +582,17 @@ def snapshot(self, *, status: dict[str, Any] | None = None, recorded_budget = next((record["budget"] for record in records if isinstance(record.get("budget"), dict)), None) latest_check = next((item for item in registered if item["kind"] == "check" and item.get("cycleId")), None) latest_project_cycle = next((cycle for cycle in cycles if cycle.get("projectStatus") == "current"), None) + try: + product_media = media_projection(self.root, selected_project, readonly=status is None) if selected_project else None + except (OSError, ValueError, TypeError, KeyError): + product_media = None + warnings.append("product_media_unavailable") return {"ok": True, "readOnly": status is None, "sourceName": self.root.name, "legacyAvailable": False, "languageState": selected_language, "status": status, "recordedBudget": recorded_budget, "generatedAt": generated_at, "language": language, "project": project, + "cycleNumbering": numbering, "productMedia": product_media, "runtime": runtime, "consensus": {"updatedAt": updated_at, "reportedUpdatedAt": timestamp(section(parts, "Last Updated", "最后更新", "最近更新")), diff --git a/dashboard/journal_server.py b/dashboard/journal_server.py index e92b3d54..137a1b08 100644 --- a/dashboard/journal_server.py +++ b/dashboard/journal_server.py @@ -98,6 +98,12 @@ def do_GET(self) -> None: # noqa: N802 self.respond(content.encode("utf-8"), LEGACY_ASSETS[name]) elif request.path == "/api/journal": self.json({**source.snapshot(), "legacyAvailable": self.server.legacy is not None}) + elif request.path.startswith("/api/product-media/"): + match = re.fullmatch(r"/api/product-media/([0-9a-f]{32})/([A-Za-z0-9_.-]+\.(?:png|svg))", request.path) + if not match or query: + raise ValueError("Invalid media identity") + raw, mime = source.media_resource(*match.groups()) + self.respond(raw, mime) elif request.path == "/api/journal/log": if set(query) != {"id"} or len(query["id"]) != 1: raise ValueError("One cycle identity is required") diff --git a/dashboard/observability_data.py b/dashboard/observability_data.py index d1851203..d75df239 100644 --- a/dashboard/observability_data.py +++ b/dashboard/observability_data.py @@ -226,8 +226,8 @@ def _artifact_entry(source, selected, record, preview_probe): return entry -def artifact_projection(source): - selected = source.pairs(".auto-company.local").get("ACTIVE_PROJECT", "") +def artifact_projection(source, selected=None): + selected = (source.project()["id"] or "") if selected is None else selected if not re.fullmatch(r"projects/[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", selected): return {"items": [], "status": "unavailable", "selectedProject": None, "invalidRecords": 0, "truncated": False, "scannedRecords": 0, "returnedRecords": 0} diff --git a/dashboard/server.py b/dashboard/server.py index 0f7c964d..134ec0dc 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -627,6 +627,32 @@ def gather_journal_payload() -> dict[str, Any]: return payload +def capture_product_media(product_id: str) -> dict[str, Any]: + """Run the bounded media command in the runtime's OS, without a model call.""" + source = JournalSource(REPO_ROOT) + project = source.project()["id"] + from product_identity import get_identity + identity = get_identity(REPO_ROOT, project, create=False) if project else None + if not identity or identity["id"] != product_id: + raise ValueError("The selected product has changed") + arguments = ["scripts/core/runtime_artifacts.py", "--project", project, "media", "--retry"] + if detect_host_kind() == WINDOWS_HOST: + conversion = subprocess.run(["wsl.exe", "-d", "Ubuntu", "--exec", "wslpath", "-a", REPO_ROOT.as_posix()], + capture_output=True, text=True, timeout=15, check=True, + creationflags=subprocess.CREATE_NO_WINDOW) + root = conversion.stdout.strip() + if not root.startswith("/") or "\n" in root or "\r" in root: + raise ValueError("Runtime path conversion failed") + command = ["wsl.exe", "-d", "Ubuntu", "--cd", root, "--exec", "python3", *arguments] + else: + command = [sys.executable, *arguments] + result = subprocess.run(command, cwd=REPO_ROOT, capture_output=True, timeout=150, + **({"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {})) + from product_media import media_projection + return {"ok": result.returncode == 0, "productMedia": media_projection(REPO_ROOT, project), + "error": None if result.returncode == 0 else "Product screenshot was not completed."} + + class DashboardHandler(BaseHTTPRequestHandler): def _request_allowed(self) -> bool: address, port = self.server.server_address[:2] @@ -679,6 +705,25 @@ def do_GET(self) -> None: # noqa: N802 parsed = urlparse(self.path) path = parsed.path + if path.startswith("/api/product-media/"): + try: + match = re.fullmatch(r"/api/product-media/([0-9a-f]{32})/([A-Za-z0-9_.-]+\.(?:png|svg))", path) + if not match or parsed.query: + raise ValueError("Invalid media identity") + raw, mime = JournalSource(REPO_ROOT).media_resource(*match.groups()) + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Content-Security-Policy", "default-src 'none'; sandbox") + self.end_headers() + self.wfile.write(raw) + except (ValueError, KeyError, UnicodeError): + self._json({"ok": False, "error": "Invalid media resource."}, code=400) + except OSError: + self._json({"ok": False, "error": "Media resource unavailable."}, code=404) + return if path in {"/api/journal", "/api/journal/log", "/api/journal/document"}: try: query = parse_qs(parsed.query, keep_blank_values=True, max_num_fields=12) @@ -771,6 +816,33 @@ def do_POST(self) -> None: # noqa: N802 return parsed = urlparse(self.path) path = parsed.path + if path == "/api/product-media/capture": + if not CONTROL_LOCK.acquire(blocking=False): + self._json({"ok": False, "error": "A runtime action is already in progress."}, code=409) + return + try: + length = int(self.headers.get("Content-Length", "0")) + if not 0 < length <= 1024 or self.headers.get("Transfer-Encoding") or parsed.query: + raise ValueError("Invalid request size") + body = json.loads(self.rfile.read(length).decode("utf-8")) + if not isinstance(body, dict) or set(body) != {"productId"} or not isinstance(body["productId"], str) or not re.fullmatch(r"[0-9a-f]{32}", body["productId"]): + raise ValueError("Invalid product identity") + status = gather_status_payload() + process = status.get("parsed", {}).get("loop", {}).get("processState") + if CONTROL_ACTION or (REPO_ROOT / ".auto-loop-stop-pending").exists() or status.get("ok") is not True or process not in {"stopped", "inactive"}: + self._json({"ok": False, "error": "Capture retries require an idle runtime."}, code=409) + return + CONTROL_ACTION = "capture" + payload = capture_product_media(body["productId"]) + self._json(payload, code=200 if payload["ok"] else 400) + except (ValueError, KeyError, UnicodeError): + self._json({"ok": False, "error": "Invalid or changed product identity."}, code=400) + except (OSError, subprocess.SubprocessError): + self._json({"ok": False, "error": "Product screenshot command unavailable."}, code=503) + finally: + CONTROL_ACTION = "" + CONTROL_LOCK.release() + return if path == "/api/language": try: length = int(self.headers.get("Content-Length", "0")) diff --git a/dashboard/styles.css b/dashboard/styles.css index ae658de9..5beedf84 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -665,6 +665,59 @@ button:focus:not(:focus-visible), main:focus { font-size: 12px; } +.exploration-section { + grid-column: 1; + grid-row: 3; + min-width: 0; +} + +.exploration-disclosure>summary { + display: flex; + align-items: center; + gap: 12px; + margin-left: var(--body-start); + border-top: 1px solid var(--line); + padding: 15px 0; + color: var(--muted); + cursor: pointer; + list-style: none; +} + +.exploration-disclosure>summary::-webkit-details-marker { + display: none; +} + +.exploration-heading { + font-size: 12px; + font-weight: 600; +} + +.exploration-note { + margin-left: auto; + font-size: 10px; + color: #9298a1; +} + +.exploration-chevron { + width: 14px; + height: 14px; + flex: 0 0 14px; + transition: transform .15s; +} + +.exploration-disclosure[open] .exploration-chevron { + transform: rotate(90deg); +} + +.exploration-list { + margin-top: 4px; +} + +.exploration-list .history-number { + font-size: 13px; + letter-spacing: 0; +} + .source-notes { font-size: 11px; line-height: 1.8; @@ -1700,6 +1753,7 @@ dialog .runtime-details { .tab, .header-actions .button, #settingsButton { display: inline-flex; align-items: center; gap: 7px; } .cycle-log-link { margin-top: 30px; } .history-heading { margin-left: var(--body-start); } +.history-group-label { position: relative; margin: 15px 0 5px var(--body-start); font-size: 12px; font-weight: 400; color: var(--muted); } .history-row > summary { grid-template-columns: var(--number-width) var(--node-size) minmax(0, 1fr) auto 12px; gap: var(--cycle-gap); } .history-number { padding-left: 0; text-align: center; grid-row: 1; } .history-title { grid-column: 3; grid-row: 1; } @@ -1712,6 +1766,15 @@ dialog .runtime-details { .project-overview h1 { font-size: 22px; font-weight: 650; line-height: 1.5; margin: 0; overflow-wrap: anywhere; } .project-description { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 14px; line-height: 1.75; color: var(--muted); margin: 8px 0 0; } .project-overview .sidebar-note { margin-top: 14px; } +.project-identity { display: flex; align-items: center; gap: 12px; } +.product-icon { flex: 0 0 36px; object-fit: contain; } +.product-media { min-width: 0; } +.product-screenshot-link { display: block; margin-top: 16px; border: 1px solid var(--line); overflow: hidden; } +.product-screenshot { display: block; width: 100%; height: auto; } +.screenshot-caption { display: flex; flex-wrap: wrap; gap: 4px 10px; } +.screenshot-stale { color: #7d7568; } +.screenshot-links { display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 13px; } +.screenshot-retry { margin-top: 13px; font-size: 13px; } .artifact-link .ui-icon { margin-top: 3px; } .artifact-link .artifact-arrow { width: 14px; height: 14px; flex-basis: 14px; padding: 0; } @media(max-width:1000px) { @@ -1724,6 +1787,7 @@ dialog .runtime-details { @media(max-width:760px) { .history-section { grid-row: 2; } .project-sidebar { grid-row: 3; padding-top: 23px; } + .exploration-section { grid-row: 4; } .project-overview { grid-column: 1 / -1; } .project-description { max-width: 560px; } .header-actions, .preview-note { flex-wrap: wrap; } @@ -1747,8 +1811,17 @@ dialog .runtime-details { .history-title { font-size: 13px; } .history-meta { font-size: 10px; white-space: normal; } .history-content { padding-right: 0; } + .exploration-disclosure>summary { margin-left: 0; flex-wrap: wrap; gap: 4px 10px; } + .exploration-note { width: 100%; margin-left: 0; } + .exploration-chevron { margin-left: auto; } + .exploration-list .history-number { font-size: 10px; } .tabs { gap: 14px; } .tab { gap: 5px; } .tab .ui-icon { width: 15px; height: 15px; flex-basis: 15px; } .navline { gap: 0 10px; } } + +/* Keep the journal connected when the media sidebar is taller than its records. */ +@media(min-width:761px) { + .journal-layout { grid-template-rows: min-content minmax(0, 1fr); } +} diff --git a/docs/product-cycles.md b/docs/product-cycles.md new file mode 100644 index 00000000..22b81ab1 --- /dev/null +++ b/docs/product-cycles.md @@ -0,0 +1,50 @@ +# Continuous product cycles + +A product's displayed work number persists across process restarts, model changes +and log rotation. An execution cycle is one coordinator attempt, not a business +stage or a completion threshold. Existing objectives, budgets, governance and +stop controls still apply. This feature does not impose a local-MVP stop or a +fixed number of cycles before delivery. + +The program owns `.auto-company/product-state.json`: stable identities, counters, +cycle IDs and pending/terminal attempts are committed together. Product source +markers detect replaced directories; their names and paths are not identity. +The language-period `AUTO_COMPANY_PRODUCT_ID` remains separate. Cycle processes +receive the new `AUTO_COMPANY_STABLE_PRODUCT_ID` and +`AUTO_COMPANY_PRODUCT_CYCLE_NUMBER` context. + +New records count from 1. Legacy usage records retain their original IDs and +run-local numbers under “Legacy runs”; their history is never silently rewritten +as a complete lifetime sequence. The adoption point is the start of continuous +counting for an existing product. Rotation cannot reset the durable counter. + +Before a product is selected, exploration has a separate identity and number. +`make project-new` explicitly links that exploration to the created product; +later automatic runs continue it without editing human selection. Early +exploration remains labeled separately. A second competing creation in the same +exploration is rejected rather than silently changing the continuation. + +Startup preflight failures do not allocate a work cycle. An allocated attempt +is never reused: a reservation without dispatch stays “not started”; dispatch +without execution evidence stays “launch unconfirmed”. Retained execution +evidence permits an interrupted classification. Recovery does not +replay a possibly executed provider call. Ordinary restart recovery is automatic; +existing governance pauses still require the existing explicit resume workflow. + +For a source directory deliberately moved within a runtime, preserve its marker +and use the identity tool's explicit `--project projects/ relocate --id ` +command (`--root` identifies the runtime). Update human selection separately if +one is set. Missing/replaced sources do not inherit the old identity by filename. + +If a writer is killed inside the short state transaction, the program retains a +lock and transaction intent. After confirming **all writers are stopped**, use: + +```sh +python3 scripts/core/product_identity.py --root /path/to/runtime recover-lock --confirm STOPPED +``` + +This replays a saved transaction intent before further writes. It is not a way +to bypass an active writer. Keep the state directory with its runtime when +backing up or restoring; deleting it discards the counting authority. Readers do +not create identities or recover locks. Multi-product management and scheduling +are separate work and are not added by this change. diff --git a/docs/product-media.md b/docs/product-media.md new file mode 100644 index 00000000..043dfd86 --- /dev/null +++ b/docs/product-media.md @@ -0,0 +1,29 @@ +# Automatic product screenshots and icons + +Normal delivery registration and cycle close capture supported product UIs +without requiring the coding model to open a preview or call a screenshot tool. +The existing single-product journal shows actual desktop/mobile images, their +source version/time and explicit missing, failed or stale states. Captures are +appearance records, not business completion or functional verification. + +For a static product, a root `index.html` is enough. Other web roots and the +explicit Node development-server contract use `.auto-company/media.json`. +Nonvisual products can declare `{"version":1,"type":"none"}`. Unknown frameworks, +external/login-dependent resources and arbitrary startup commands are not guessed. + +The optional screenshot runtime uses Node 20+ and the same pinned Playwright +Chromium dependency as the existing browser checks. Install it in the product's +runtime OS; capture never installs tools automatically. Missing tools preserve +the last valid image and produce a diagnostic. A manual retry does not invoke AI. + +During capture/delivery, supported static HTML entries also receive a marked +favicon metadata link when one is missing and the validated canonical icon is +available. Correct references and existing usable user favicons are preserved; +different user icons, conflicts and dynamic/unknown references are reported +explicitly. This does not rewrite business content or add visible interface +elements. Read-only pages only inspect linkage and never repair it. + +See [the media runtime contract](../scripts/media/README.md) for installation, +profile examples, safe SVG rules, cleanup and retention, explicit retry commands, +and the pure-read projection/resource APIs. Generated captures stay under +`logs/product-media//`; read-only archives cannot start capture. diff --git a/i18n/en/PROMPT.md b/i18n/en/PROMPT.md index 8274a2fe..738cb128 100644 --- a/i18n/en/PROMPT.md +++ b/i18n/en/PROMPT.md @@ -66,6 +66,8 @@ Before finishing, you **must** update `memories/consensus.md` in this format: ## Convergence Rules (Mandatory) +Cycle 1/2/3 below describe convergence for a **new exploration task**, not a business reset on every process launch. Continue an existing product or recorded exploration from its actual consensus. Program-provided product cycle numbers count accumulated work records; they are not business phases and do not prescribe a number of cycles before delivery. Stopping, restarting or changing models must not restart idea selection merely because a run-local counter is 1. + 1. **Cycle 1**: Brainstorm. Each agent proposes one idea; rank the top 3 before finishing 2. **Cycle 2**: Pick #1. Have critic-munger run a Pre-Mortem, research-thompson validate the market, and cfo-campbell work out the numbers. Give a GO / NO-GO decision 3. **Cycle 3+**: GO → Create a repo and start writing code; further discussion is prohibited. NO-GO → Try #2; if none work, force a choice and build it @@ -73,6 +75,11 @@ Before finishing, you **must** update `memories/consensus.md` in this format: 5. **The same Next Action appears for 2 consecutive cycles** → You are stuck; change direction or narrow the scope and ship 6. **Any frontend deliverable** (page, interface, component, dashboard, marketing site) → You must use `frontend-design.md` first to ensure visual and interaction quality; shipping a generic default style is not allowed +## Product pages and identity assets + +- Reuse an existing valid SVG icon for frontend products. Otherwise provide a simple `icon.svg` with a `viewBox`, few colors and basic shapes. Reference `auto-company-icon.svg` in the Web root from both the product interface and favicon: the program writes this validated asset at delivery or cycle close; do not create or modify it manually. Do not include scripts, external resources, embedded HTML or complex filters, and do not call a dedicated image model. The program validates the input icon and supplies a default when missing or invalid; icon issues must not block feature delivery. +- The program captures actual pages at cycle close and after delivery-document registration. Static Web products with a root `index.html` require no separate screenshot command. Declare other supported entrypoints and any fixed display steps as documented in `docs/product-media.md`. Mark non-UI products as not applicable; never fabricate a page. Screenshot failure is separate from feature-check failure, and concept images must not replace real captures. + ## Human Governance and Project Boundaries (Mandatory) 1. `Human Overrides` is a human-only section and must be preserved verbatim; any change triggers a rollback of the entire cycle's consensus and pauses the loop. diff --git a/i18n/source-hashes.json b/i18n/source-hashes.json index b4bd9ec3..d2168f86 100644 --- a/i18n/source-hashes.json +++ b/i18n/source-hashes.json @@ -1,6 +1,6 @@ { "INDEX.md": "bb3baf532639b7f7de882fa78c2eae385964099bdcc286f4f7608ac46a7a9526", - "PROMPT.md": "432e5c73674a2643ff739f47f3cb881cc95586744d2d1f46b9d9c7decdc4dc22", + "PROMPT.md": "adb11c6045ce6a3af8a065c574e5dde28517d6d2ad4cefa5879887616f4a66cd", ".claude/agents/ceo-bezos.md": "04655828d6c110787651efc71d53ab2e6d04f26356b3c7de8f00c909aa645de2", ".claude/agents/cfo-campbell.md": "63186e51dad6191930907dd8f0847fa8d78080c43f3d9d1576629c30b5f01545", ".claude/agents/critic-munger.md": "ca59c72d8b8a91436b1fe60866af2318bb0540e83551c244882427c6fc52b18b", diff --git a/package.json b/package.json index 37dbdcfd..ea36846c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auto-company-clone-win", - "version": "1.5.0", + "version": "1.6.0", "private": true, "description": "Auto Company Windows + WSL runtime with dual-engine CLI support", "license": "MIT" diff --git a/presentation/dashboard-showcase.png b/presentation/dashboard-showcase.png index 1cb74ccc..f4915a04 100644 Binary files a/presentation/dashboard-showcase.png and b/presentation/dashboard-showcase.png differ diff --git a/presentation/dashboards/scope-sheet.png b/presentation/dashboards/scope-sheet.png new file mode 100644 index 00000000..b75b22fa Binary files /dev/null and b/presentation/dashboards/scope-sheet.png differ diff --git a/presentation/dashboards/text-meter.png b/presentation/dashboards/text-meter.png new file mode 100644 index 00000000..b1f8fbb8 Binary files /dev/null and b/presentation/dashboards/text-meter.png differ diff --git a/presentation/products/scope-sheet-full.png b/presentation/products/scope-sheet-full.png new file mode 100644 index 00000000..93420707 Binary files /dev/null and b/presentation/products/scope-sheet-full.png differ diff --git a/presentation/products/scope-sheet-mobile.png b/presentation/products/scope-sheet-mobile.png new file mode 100644 index 00000000..af49ecc2 Binary files /dev/null and b/presentation/products/scope-sheet-mobile.png differ diff --git a/presentation/products/scope-sheet.png b/presentation/products/scope-sheet.png new file mode 100644 index 00000000..b3f0be66 Binary files /dev/null and b/presentation/products/scope-sheet.png differ diff --git a/presentation/products/scopefence-full.png b/presentation/products/scopefence-full.png new file mode 100644 index 00000000..45e100d5 Binary files /dev/null and b/presentation/products/scopefence-full.png differ diff --git a/presentation/products/scopefence-mobile.png b/presentation/products/scopefence-mobile.png new file mode 100644 index 00000000..2890fa3a Binary files /dev/null and b/presentation/products/scopefence-mobile.png differ diff --git a/presentation/products/scopefence.png b/presentation/products/scopefence.png new file mode 100644 index 00000000..cbfe5ead Binary files /dev/null and b/presentation/products/scopefence.png differ diff --git a/presentation/products/text-meter-full.png b/presentation/products/text-meter-full.png new file mode 100644 index 00000000..31969973 Binary files /dev/null and b/presentation/products/text-meter-full.png differ diff --git a/presentation/products/text-meter-mobile.png b/presentation/products/text-meter-mobile.png new file mode 100644 index 00000000..f9e55b6b Binary files /dev/null and b/presentation/products/text-meter-mobile.png differ diff --git a/presentation/products/text-meter.png b/presentation/products/text-meter.png new file mode 100644 index 00000000..997a2fd0 Binary files /dev/null and b/presentation/products/text-meter.png differ diff --git a/projects/README.md b/projects/README.md index 94eaee18..53ae26f4 100644 --- a/projects/README.md +++ b/projects/README.md @@ -7,9 +7,12 @@ | [SnapOG](snapog/) | Existing Open Graph image prototype | See its project README | | [行间 / TableDelta](tabledelta/) | Compare two CSV files by a unique key and export changes as JSON | `cd projects/tabledelta && python3 -m http.server 8765 --bind 127.0.0.1` | | [幕检 / CueCheck](cuecheck/) | Review, edit, and export SRT subtitles locally | `cd projects/cuecheck && python3 -m http.server 8766 --bind 127.0.0.1` | +| [ScopeFence](scopefence/) | Share a scope-change decision as an editable, self-contained link | `cd projects/scopefence && python3 -m http.server 8000 --bind 127.0.0.1` | +| [Text Meter](text-meter/) | Count Unicode code points, whitespace-separated words and lines locally | `cd projects/text-meter && python3 -m http.server 8000 --bind 127.0.0.1` | +| [范围确认单 / Scope Sheet](scope-sheet/) | Prepare a Chinese project-scope note, check required fields and copy or download it | `cd projects/scope-sheet && python3 -m http.server 8000 --bind 127.0.0.1` | -TableDelta and CueCheck are explicitly published source snapshots of local MVPs. -Each folder includes its own instructions, examples, and tests. Their original +The published snapshots are local prototypes with their own usage instructions +and documented limits. The newer examples also include source provenance. Their original independent local repositories are preserved; nested Git metadata, runtime logs, browser caches, and private company planning documents are not included. These tracked examples are not selectable independent runtime repositories. diff --git a/projects/registry.tsv b/projects/registry.tsv index 291c9a66..70e6e9a3 100644 --- a/projects/registry.tsv +++ b/projects/registry.tsv @@ -3,3 +3,6 @@ ar-collections-assistant projects/ar-collections-assistant historical-local-proj snapog projects/snapog legacy-tracked-pending-reviewed-migration unknown tabledelta projects/tabledelta human-published-source-snapshot 2026-09-18T04:32:17Z cuecheck projects/cuecheck human-published-source-snapshot 2026-09-18T05:31:45Z +scopefence projects/scopefence human-published-source-snapshot 2026-09-19T11:19:30Z +text-meter projects/text-meter human-published-source-snapshot 2026-09-19T10:39:29Z +scope-sheet projects/scope-sheet human-published-source-snapshot 2026-09-19T11:59:44Z diff --git a/projects/scope-sheet/.gitignore b/projects/scope-sheet/.gitignore new file mode 100644 index 00000000..bf9732e8 --- /dev/null +++ b/projects/scope-sheet/.gitignore @@ -0,0 +1,6 @@ +# Local secrets and generated output +.env +.env.* +dist/ +build/ +.auto-company/ diff --git a/projects/scope-sheet/README.md b/projects/scope-sheet/README.md new file mode 100644 index 00000000..67b584af --- /dev/null +++ b/projects/scope-sheet/README.md @@ -0,0 +1,45 @@ +# 范围确认单 + +给独立设计师与剪辑师的中文单页工具:把合作范围、修改、交付、日期和报价整理成可直接发送的确认单。 + +## 本地运行 + +在项目目录启动本地静态服务器: + +```sh +python3 -m http.server 8000 --bind 127.0.0.1 +``` + +Windows 可将 `python3` 换成 `py`。然后打开 。页面不需要账号、后端或第三方运行依赖。 + +## 核心操作 + +1. 填写左侧项目资料,右侧确认单会实时更新。 +2. 可按需勾选“请客户确认后再开工”,编辑随确认单发出的确认请求。 +3. 补齐项目、服务、范围、修改、交付、日期与报价后,选择“复制确认单文字”发送给客户,或下载 `.txt` 留档。 +4. 首次体验可使用“载入示例”。 + +导出前会明确提示并标出缺少的关键边界或无效报价,并聚焦第一个待填写字段;客户/品牌、开工前备注和确认请求可按实际情况留空。表单不会因在单行输入框按 Enter 而提交或清空。 + +## 产品边界 + +- 填写内容只在当前浏览器页面中处理;产品不会上传、同步或保存内容。 +- “复制确认单文字”需要浏览器允许剪贴板操作;“下载 .txt”只生成本地文本文件。 +- 产品不会自动发送消息,也不提供登录、云端保存、PDF、在线签署、支付、报价建议或法律合同功能。 +- “请客户确认后再开工”只会把确认请求加入导出文字;确认仍需在用户原有的沟通渠道中完成。 + +## 项目来源 + +本项目是在“最终产品必须是中文 Web 应用”的输出约束下,由 Auto Company 自主选题并实现。该流程共进行了四次产品轮次:前两次因模型服务容量不足而失败,后两次完成。本快照对应第 04 轮结束时的产品状态。公开源码不包含原始模型日志或内部运行资料。 + +## 检查 + +项目目前只有 JavaScript 语法检查,没有单元测试套件: + +```sh +npm test +``` + +## 许可与资源 + +项目随 Auto Company 仓库发布时适用仓库根目录的 MIT `LICENSE`。几何 SVG 是 Auto Company 产品媒体流程生成的默认图标;项目不包含外部图片、字体文件、远程脚本、分析服务或其他第三方资源。 diff --git a/projects/scope-sheet/SOURCE.md b/projects/scope-sheet/SOURCE.md new file mode 100644 index 00000000..d9f5effa --- /dev/null +++ b/projects/scope-sheet/SOURCE.md @@ -0,0 +1,7 @@ +# 来源说明 + +范围确认单是在“最终产品必须是中文 Web 应用”的输出约束下,由 Auto Company 自主选题并实现的产品。原流程共有四次产品轮次:前两次因模型服务容量不足而失败,后两次完成。本快照对应第 04 轮结束时的产品状态。 + +公开快照保留最终验收的应用、样式和默认图标;`index.html` 仅清理一行行尾空白,其他三个文件保持原字节。另加入公开 README、最小私有 package 元数据和本来源说明。仓库中不包含原始模型日志、会话记录、运行标识、内部角色文档、交付记录或私有验收证据。 + +项目没有后端、远程资源、分析服务或第三方运行依赖。几何 SVG 是 Auto Company 产品媒体流程生成的默认图标。 diff --git a/projects/scope-sheet/app.js b/projects/scope-sheet/app.js new file mode 100644 index 00000000..110f403b --- /dev/null +++ b/projects/scope-sheet/app.js @@ -0,0 +1,186 @@ +const form = document.querySelector('#scope-form'); +const actionMessage = document.querySelector('#action-message'); +const sendCheck = document.querySelector('#send-check'); +const confirmationToggle = form.elements.requestConfirmation; +const confirmationMessage = document.querySelector('#confirmation-message'); + +const fields = ['client', 'project', 'service', 'scope', 'revisions', 'deliverable', 'deadline', 'price', 'note', 'confirmation']; +const sendRequiredFields = [ + ['project', '项目名称'], + ['service', '本次服务'], + ['scope', '工作范围'], + ['revisions', '修改次数'], + ['deliverable', '交付内容'], + ['deadline', '交付日期'], + ['price', '报价'] +]; +const example = { + client: '春日咖啡', + project: '秋季新品短片剪辑', + service: '30 秒品牌短片剪辑', + scope: '整理并筛选客户提供的素材;完成粗剪、精剪、基础调色与字幕排版。', + revisions: '含 2 轮修改(以同一版方向为准)', + deliverable: '1 条 9:16 竖版 MP4(1080 × 1920)', + deadline: '2026-09-26', + price: '2800', + note: '请在开工前一次性提供最终文案、Logo 与品牌字体。' +}; + +function value(name) { + return form.elements[name].value.trim(); +} + +function formatDate(input) { + if (!input) return '—'; + const [year, month, day] = input.split('-'); + return `${year} 年 ${Number(month)} 月 ${Number(day)} 日`; +} + +function priceNumber(input) { + const raw = String(input).trim().replace(/,/g, ','); + if (!raw) return null; + if (!/^(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d+)?$/.test(raw)) return null; + const numeric = Number(raw.replace(/,/g, '')); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; +} + +function formatPrice(input) { + const numeric = priceNumber(input); + return numeric !== null + ? `¥ ${numeric.toLocaleString('zh-CN')}` + : '¥ —'; +} + +function setText(id, text, fallback = '—') { + document.querySelector(id).textContent = text || fallback; +} + +function missingSendFields() { + const missing = sendRequiredFields.filter(([name]) => !value(name)); + const hasInvalidPrice = value('price') && priceNumber(value('price')) === null; + return hasInvalidPrice ? [...missing, ['price', '报价(请填写非负金额)']] : missing; +} + +function updateSendCheck() { + const missing = missingSendFields(); + const complete = missing.length === 0; + sendCheck.textContent = complete + ? '发送前校对:项目、服务、范围、修改、交付、日期和报价已齐。' + : `发送前校对:还差 ${missing.map(([, label]) => label).join('、')}。`; + sendCheck.classList.toggle('is-ready', complete); +} + +function ensureReadyToSend() { + const missing = missingSendFields(); + if (missing.length === 0) return true; + + sendRequiredFields.forEach(([name]) => { + const invalid = missing.some(([missingName]) => missingName === name); + form.elements[name].classList.toggle('is-invalid', invalid); + form.elements[name].setAttribute('aria-invalid', String(invalid)); + }); + const [firstName, firstLabel] = missing[0]; + form.elements[firstName].focus(); + actionMessage.textContent = `请先补齐${missing.map(([, label]) => label).join('、')},再导出。`; + return false; +} + +function confirmationText() { + const data = Object.fromEntries(fields.map((field) => [field, value(field)])); + const rows = [ + '范围确认单', + data.project || '未命名项目', + '', + `客户:${data.client || '—'}`, + `服务:${data.service || '—'}`, + `交付:${data.deliverable || '—'}`, + `交付日期:${formatDate(data.deadline)}`, + '', + '本次包含:', + data.scope || '—', + '', + `修改安排:${data.revisions || '—'}`, + `本次合作报价:${formatPrice(data.price)}` + ]; + if (data.note) rows.push('', '开工前请确认:', data.note); + if (confirmationToggle.checked && data.confirmation) rows.push('', '请确认:', data.confirmation); + return rows.join('\n'); +} + +function syncConfirmationRequest() { + confirmationMessage.hidden = !confirmationToggle.checked; +} + +function updatePreview() { + const project = value('project'); + setText('#doc-project', project ? `项目:${project}` : '', '填写左侧内容后,这里会生成你的项目确认单。'); + setText('#doc-client', value('client')); + setText('#doc-service', value('service')); + setText('#doc-deliverable', value('deliverable')); + setText('#doc-deadline', formatDate(value('deadline'))); + setText('#doc-scope', value('scope'), '尚未填写工作范围。'); + setText('#doc-revisions', value('revisions')); + setText('#doc-price', formatPrice(value('price'))); + const note = value('note'); + document.querySelector('#note-section').hidden = !note; + document.querySelector('#doc-note').textContent = note; + const confirmation = value('confirmation'); + document.querySelector('#confirmation-section').hidden = !confirmationToggle.checked || !confirmation; + document.querySelector('#doc-confirmation').textContent = confirmation; + updateSendCheck(); +} + +async function copyText() { + if (!ensureReadyToSend()) return; + const text = confirmationText(); + try { + await navigator.clipboard.writeText(text); + actionMessage.textContent = '已复制。现在可以粘贴到微信、邮件或项目群。'; + } catch { + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.append(area); + area.select(); + const copied = document.execCommand('copy'); + area.remove(); + actionMessage.textContent = copied ? '已复制。现在可以粘贴到微信、邮件或项目群。' : '复制未成功,请使用下载文本文件。'; + } +} + +function downloadText() { + if (!ensureReadyToSend()) return; + const blob = new Blob([confirmationText()], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const safeName = (value('project') || '范围确认单').replace(/[\\/:*?"<>|]/g, '-'); + link.href = url; + link.download = `${safeName}-范围确认单.txt`; + link.click(); + URL.revokeObjectURL(url); + actionMessage.textContent = '已开始下载,可作为项目留档或直接发送给客户。'; +} + +form.addEventListener('submit', (event) => event.preventDefault()); +form.addEventListener('input', updatePreview); +form.addEventListener('input', (event) => { + if (event.target.name && sendRequiredFields.some(([name]) => name === event.target.name)) { + event.target.classList.remove('is-invalid'); + event.target.setAttribute('aria-invalid', 'false'); + } +}); +confirmationToggle.addEventListener('change', () => { + syncConfirmationRequest(); + updatePreview(); +}); +document.querySelector('#copy-button').addEventListener('click', copyText); +document.querySelector('#download-button').addEventListener('click', downloadText); +document.querySelector('#example-button').addEventListener('click', () => { + Object.entries(example).forEach(([name, content]) => { form.elements[name].value = content; }); + updatePreview(); + actionMessage.textContent = '已载入一组示例。可以直接改成你的项目。'; +}); +syncConfirmationRequest(); +updatePreview(); diff --git a/projects/scope-sheet/auto-company-icon.svg b/projects/scope-sheet/auto-company-icon.svg new file mode 100644 index 00000000..ec80191a --- /dev/null +++ b/projects/scope-sheet/auto-company-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/projects/scope-sheet/index.html b/projects/scope-sheet/index.html new file mode 100644 index 00000000..5c1947fb --- /dev/null +++ b/projects/scope-sheet/index.html @@ -0,0 +1,157 @@ + + + + + + + 范围确认单|把合作边界说清楚 + + + + + +
+
+ + + 范围确认单 + +

项目单 · 01

+
+ +
+

给独立设计师与剪辑师

+

把“差不多”
写成说得清楚。

+

填好左侧信息,右侧会同步生成一张可以直接发给客户的范围确认单。

+
+ +
+
+
+
+

01 / 编辑

+

这次合作,怎么做?

+
+ +
+ +
+ + +
+ + + + + +
+ + +
+ +
+ + +
+ + + +
+ + +
+
+ +
+ +
+

合作范围确认

+
+

范围确认单

+ 待确认 +
+

填写左侧内容后,这里会生成你的项目确认单。

+
+ +
+
客户
—
+
服务
—
+
交付
—
+
日期
—
+
+ +
+

本次包含

+

尚未填写工作范围。

+
+
+

修改安排

+

—

+
+ + + +
+

本次合作报价

+ ¥ — +
+
+
+ +
+
+

02 / 发送

+

确认无误,就发出去。

+

发送前校对:请补齐项目、服务、范围、修改、交付、日期和报价。

+

可复制为聊天文字,或下载为文本文件留档。

+
+
+ + +
+
+
+
先说清范围,再开始创作。所有内容只在你的浏览器中生成。
+ + + diff --git a/projects/scope-sheet/package.json b/projects/scope-sheet/package.json new file mode 100644 index 00000000..63384f5e --- /dev/null +++ b/projects/scope-sheet/package.json @@ -0,0 +1,9 @@ +{ + "name": "scope-sheet", + "private": true, + "version": "1.0.0", + "license": "MIT", + "scripts": { + "test": "node --check app.js" + } +} diff --git a/projects/scope-sheet/styles.css b/projects/scope-sheet/styles.css new file mode 100644 index 00000000..c07f6940 --- /dev/null +++ b/projects/scope-sheet/styles.css @@ -0,0 +1,109 @@ +:root { + --ink: #17231d; + --pine: #1f3b2c; + --moss: #829c67; + --paper: #f4f0e6; + --paper-deep: #e9e1cf; + --coral: #e86542; + --line: #b8b09e; + --muted: #6b6a60; + --display: "Songti SC", "STSong", "Noto Serif CJK SC", serif; + --body: "PingFang SC", "Microsoft YaHei", sans-serif; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + color: var(--ink); + font-family: var(--body); + background-color: var(--paper); + background-image: radial-gradient(rgba(31, 59, 44, .09) .65px, transparent .75px); + background-size: 8px 8px; +} +button, input, textarea { font: inherit; } +button { cursor: pointer; } +.workspace { width: min(1180px, calc(100% - 48px)); margin: 0 auto; } +.masthead { min-height: 86px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--ink); } +.brand { color: inherit; text-decoration: none; display: flex; gap: 9px; align-items: center; font-weight: 800; letter-spacing: -.04em; } +.brand-mark { color: var(--coral); font-family: Georgia, serif; font-size: 30px; line-height: 1; letter-spacing: -9px; } +.edition, .eyebrow, .section-number, .doc-kicker, .doc-label { margin: 0; font-size: 11px; font-weight: 800; letter-spacing: .12em; } +.edition { color: var(--muted); } +.intro { padding: 72px 0 62px; display: grid; grid-template-columns: 1.05fr .95fr; align-items: end; column-gap: 8vw; } +.eyebrow { color: var(--coral); grid-column: 1; margin-bottom: 14px; } +h1, h2, p { margin-top: 0; } +h1 { grid-column: 1; margin-bottom: 0; font: 600 clamp(44px, 6vw, 82px)/.98 var(--display); letter-spacing: -.08em; } +h1 strong { color: var(--coral); font-weight: 600; } +.intro > p:last-child { grid-column: 2; grid-row: 2; margin: 0 0 7px; color: var(--muted); line-height: 1.75; max-width: 29em; } +.maker { display: grid; grid-template-columns: minmax(320px, .84fr) minmax(370px, 1.16fr); align-items: start; border-top: 1px solid var(--ink); } +.editor { padding: 34px 42px 45px 0; } +.editor-head { display: flex; gap: 20px; align-items: flex-start; justify-content: space-between; margin-bottom: 30px; } +.section-number { color: var(--coral); } +.editor h2, .actions h2 { margin: 7px 0 0; font: 600 28px/1.1 var(--display); letter-spacing: -.055em; } +.quiet-button { color: var(--pine); background: transparent; border: 1px solid var(--pine); padding: 7px 10px; font-size: 12px; font-weight: 700; border-radius: 2px; } +.quiet-button:hover { color: var(--paper); background: var(--pine); } +label { display: block; margin-top: 19px; font-size: 13px; font-weight: 800; letter-spacing: .02em; } +input, textarea { width: 100%; margin-top: 8px; padding: 11px 0; color: var(--ink); background: transparent; border: 0; border-bottom: 1px solid var(--line); outline: none; border-radius: 0; font-size: 15px; font-weight: 400; letter-spacing: 0; } +textarea { resize: vertical; min-height: 68px; line-height: 1.55; } +input:focus, textarea:focus { border-color: var(--coral); box-shadow: 0 2px 0 var(--coral); } +input::placeholder, textarea::placeholder { color: #938f84; } +input.is-invalid, textarea.is-invalid { border-color: var(--coral); box-shadow: 0 2px 0 var(--coral); } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +.form-grid label { min-width: 0; } +.hint { display: block; margin-top: 7px; color: var(--muted); font-size: 12px; font-weight: 400; letter-spacing: 0; } +.confirmation-control { margin: 24px 0 0; padding: 13px 0 0; border: 0; border-top: 1px solid var(--line); } +.check-label { display: flex; align-items: center; gap: 9px; margin: 0; color: var(--pine); font-size: 13px; } +.check-label input { width: 16px; height: 16px; margin: 0; accent-color: var(--coral); } +.confirmation-message { margin-top: 14px; } +.document { position: sticky; top: 25px; overflow: hidden; min-height: 604px; margin: 34px 0 34px 26px; padding: 44px clamp(26px, 5vw, 62px) 35px; color: #f6f0df; background: var(--pine); box-shadow: 14px 14px 0 var(--moss); } +.paper-grain { position: absolute; inset: 0; opacity: .45; pointer-events: none; background-image: repeating-linear-gradient(90deg, transparent 0, transparent 3px, rgba(255,255,255,.035) 3px, rgba(255,255,255,.035) 4px); } +.document > *:not(.paper-grain) { position: relative; } +.doc-kicker { color: #cbd7b4; } +.doc-title-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 15px; padding: 12px 0 9px; border-bottom: 1px solid rgba(246, 240, 223, .4); } +.doc-title-row h2 { margin: 0; font: 600 clamp(31px, 4vw, 47px)/1 var(--display); letter-spacing: -.075em; } +.status { padding: 5px 8px; color: var(--pine); background: #cbd7b4; font-size: 11px; font-weight: 800; } +.doc-subtitle { min-height: 22px; margin: 13px 0 0; color: #ced4c4; line-height: 1.55; font-size: 13px; } +.doc-meta { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px 24px; margin: 37px 0 30px; } +.doc-meta div { border-bottom: 1px solid rgba(246,240,223,.23); padding-bottom: 8px; } +dt { color: #cbd7b4; font-size: 11px; font-weight: 800; letter-spacing: .1em; } +dd { margin: 6px 0 0; font-size: 15px; line-height: 1.4; word-break: break-word; } +.doc-section { padding: 17px 0; border-top: 1px solid rgba(246,240,223,.34); } +.doc-label { color: #cbd7b4; } +.doc-copy { margin: 8px 0 0; white-space: pre-wrap; line-height: 1.7; font-size: 15px; } +.small-section { padding-block: 13px; } +.document-footer { display: flex; justify-content: space-between; align-items: end; margin-top: 19px; padding-top: 19px; border-top: 2px solid #f6f0df; } +.document-footer p { margin: 0; color: #cbd7b4; font-size: 12px; font-weight: 800; } +.document-footer strong { font: 600 31px/1 var(--display); letter-spacing: -.05em; } +.actions { display: flex; align-items: end; justify-content: space-between; gap: 32px; padding: 39px 0 56px; border-top: 1px solid var(--ink); } +.actions > div:first-child > p:not(.section-number) { margin: 11px 0 0; color: var(--muted); font-size: 13px; } +.send-check { color: var(--coral) !important; font-weight: 800; } +.send-check.is-ready { color: var(--pine) !important; } +.action-message { min-height: 20px; } +.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; } +.action-primary, .action-secondary { min-height: 45px; padding: 0 18px; border-radius: 2px; font-size: 14px; font-weight: 800; } +.action-primary { color: var(--paper); border: 1px solid var(--coral); background: var(--coral); box-shadow: 4px 4px 0 var(--ink); } +.action-primary:hover { transform: translate(2px, 2px); box-shadow: 2px 2px 0 var(--ink); } +.action-secondary { color: var(--ink); border: 1px solid var(--ink); background: transparent; } +.action-secondary:hover { background: var(--paper-deep); } +.site-footer { padding: 18px 24px; color: #d8dfcc; background: var(--ink); text-align: center; font-size: 12px; } +@media (max-width: 760px) { + .workspace { width: min(100% - 30px, 620px); } + .masthead { min-height: 68px; } + .edition { display: none; } + .intro { display: block; padding: 52px 0 47px; } + .intro > p:last-child { margin-top: 25px; } + .maker { display: block; } + .editor { padding: 28px 0 42px; } + .document { position: relative; top: 0; min-height: auto; margin: 0 0 28px; padding: 34px 25px 30px; box-shadow: 9px 9px 0 var(--moss); } + .actions { display: block; padding: 32px 0 42px; } + .action-buttons { justify-content: flex-start; margin-top: 22px; } +} +@media (max-width: 420px) { + .form-grid, .doc-meta { grid-template-columns: 1fr; gap: 8px; } + .form-grid.split { gap: 8px; } + h1 { font-size: 47px; } + .editor-head { display: block; } + .quiet-button { margin-top: 18px; } + .action-buttons { display: grid; grid-template-columns: 1fr; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } } diff --git a/projects/scopefence/.gitignore b/projects/scopefence/.gitignore new file mode 100644 index 00000000..bf9732e8 --- /dev/null +++ b/projects/scopefence/.gitignore @@ -0,0 +1,6 @@ +# Local secrets and generated output +.env +.env.* +dist/ +build/ +.auto-company/ diff --git a/projects/scopefence/README.md b/projects/scopefence/README.md new file mode 100644 index 00000000..232eb1be --- /dev/null +++ b/projects/scopefence/README.md @@ -0,0 +1,40 @@ +# ScopeFence + +ScopeFence turns a freelance scope change into a clear, no-login decision link. Enter the project, changed work, price impact, and timeline impact; send the generated link; then ask the client to return the updated link after selecting approve or decline. + +## Run locally + +Serve this directory over loopback HTTP: + +```sh +python3 -m http.server 8000 --bind 127.0.0.1 +``` + +On Windows, use `py` instead of `python3`. Open . + +## Test + +The project has no third-party runtime or test dependencies. With a current Node.js release installed: + +```sh +npm test +``` + +## What the link means + +The receipt data and selected response are stored as JSON encoded in the URL fragment. The fragment is not sent to the static HTTP server, but it is visible to anyone who receives the link and can remain in browser history, the clipboard, email, or chat. + +Encoding is not signing. Anyone with the link can decode and alter its contents. ScopeFence does not authenticate either party, prove who selected a response, provide a tamper-evident audit log, create a contract, send email, or process payment. Treat the returned link as a portable communication copy and confirm the choice in the original conversation before relying on it. + +The page validates the supported fields and rejects malformed final-decision payloads, but validation does not make a link authoritative. + +## Browser support and limits + +- ScopeFence uses modern browser APIs including ES modules, URL fragments, Clipboard, `crypto.randomUUID`, `TextEncoder`, and `TextDecoder`. +- Receipt fields are deliberately bounded, but long content still produces a long URL. Messaging tools and browsers can impose their own URL limits. +- Data remains on the device unless a user copies or shares the link. The application has no backend and makes no network requests beyond loading its own static files. +- A response link is editable and can be copied out of context. It is not suitable as a verified approval record. + +## License and asset source + +This snapshot is covered by the repository's [MIT license](../../LICENSE). The geometric SVG icon is a program-generated fallback asset from Auto Company's product-media flow; no external images, fonts, scripts, or styles are bundled. See [source and review history](SOURCE.md) for the autonomous run and publication fixes. diff --git a/projects/scopefence/SOURCE.md b/projects/scopefence/SOURCE.md new file mode 100644 index 00000000..a406fd50 --- /dev/null +++ b/projects/scopefence/SOURCE.md @@ -0,0 +1,15 @@ +# Source note + +This source snapshot was prepared from the ScopeFence product created by an autonomous Auto Company run on 2026-09-19. The run chose the product direction and completed four product cycles after three exploration cycles. Human input set the execution permissions, language and external run boundary; publication review then made the fixes listed below. + +The snapshot includes only the static product, its deterministic tests, public README, ignore rules, and program-generated fallback icon. It excludes the nested Git repository, Auto Company identity/check state, model records, role reports, internal milestone documents, logs, memories, and local runtime files. + +Post-run publication fixes are limited to: + +- re-rendering when the URL fragment changes in the same browser tab; +- rejecting unsupported or incomplete final-decision payloads; +- rejecting blank numeric fields and bounding receipt IDs/payloads; +- replacing authority or immutability claims with the actual editable-link trust boundary; +- adding deterministic ESM package metadata and public usage/source documentation. + +No backend, signing, authentication, analytics, remote asset, or third-party package was added. The original run and its historical records remain unchanged. The run does not establish customer validation or commercial readiness. diff --git a/projects/scopefence/app.js b/projects/scopefence/app.js new file mode 100644 index 00000000..c9f94569 --- /dev/null +++ b/projects/scopefence/app.js @@ -0,0 +1,97 @@ +import { SAMPLE_RECEIPT, formatDays, formatPrice, makeReceipt, receiptFromHash, validateReceipt, encodeReceipt } from "./scopefence-core.js"; + +const app = document.querySelector("#app"); +const escapeHtml = (value) => String(value ?? "").replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char])); +const linkFor = (receipt) => `${location.href.split("#")[0]}#r=${encodeReceipt(receipt)}`; +const formattedTime = (time) => new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }).format(new Date(time)) + " UTC"; + +function wordmark() { + return ``; +} + +function fence() { return ``; } + +function receiptDetail(receipt) { + return `
+

INSIDE THE FENCE

${escapeHtml(receipt.project)}

${escapeHtml(receipt.change)}

${receipt.context ? `

${escapeHtml(receipt.context)}

` : ""}
+ ${fence()} +

THE NEW WORK CHANGES

Price impact
${formatPrice(receipt.price)}
Timeline impact
${formatDays(receipt.days)}
+
`; +} + +function createView(values = {}, errors = {}) { + app.innerHTML = `${wordmark()}

SCOPE DECISION / 01

Make the boundary visible
before the work begins.

Turn a post-call change into one plain-English decision your client can open without an account.

+

DRAW THE RECEIPT

State the changed work and its consequence. Nothing more.

+

${errors.project || ""}

+

Use the words your client used on the call.

${errors.change || ""}

+

${errors.price || ""}

${errors.days || ""}

+

${errors.context || ""}

+ +
`; + document.querySelector("#receipt-form").addEventListener("submit", submitReceipt); + document.querySelector("#sample-button").addEventListener("click", () => showReceipt(SAMPLE_RECEIPT)); +} + +function submitReceipt(event) { + event.preventDefault(); + const values = Object.fromEntries(new FormData(event.currentTarget)); + const errors = validateReceipt(values); + if (Object.keys(errors).length) { createView(values, errors); document.querySelector(".error:not(:empty)")?.previousElementSibling?.focus(); return; } + showReceipt(makeReceipt(values), true); +} + +function showReceipt(receipt, maker = false) { + const publicLink = linkFor(receipt); + app.innerHTML = `${wordmark()}

${receipt.sample ? "SAMPLE — NOT A LIVE CLIENT DECISION" : "SCOPE RECEIPT / READY TO DECIDE"}

Does this request belong
in the agreed work?

Read the change and its impact. Then make one clear choice.

${receiptDetail(receipt)}

THE DECISION

Choose what happens next.

Your selection can be carried in an updated link and returned to the freelancer. The link is editable and is not a signed or independently verified approval record.

${maker ? makerPanel(publicLink, receipt) : ""}
`; + document.querySelectorAll("[data-decision]").forEach((button) => button.addEventListener("click", () => confirmDecision(receipt, button.dataset.decision))); + bindCopies(); +} + +function makerPanel(link, receipt) { + const email = `Subject: Decision needed — ${receipt.project}\n\nHi,\n\nPlease review this scope receipt and choose approve or decline: ${link}\n\nIt covers: ${receipt.change}\n\nThanks.`; + return ``; +} + +function finalReturnPanel(link) { + return `

RETURN THE LINK COPY

Send this updated link back.

Paste it into the same email or message thread. It carries your selected response, but it is editable and is not independently verified.

`; +} + +function confirmDecision(receipt, decision) { + const action = decision === "approved" ? "approval" : "decline"; + const panel = document.querySelector(".decision-panel"); + panel.innerHTML = `

CONFIRM ${action.toUpperCase()}

${decision === "approved" ? "Approve this changed work?" : "Decline this changed work?"}

${decision === "approved" ? `You are confirming ${formatPrice(receipt.price)} and ${formatDays(receipt.days)}.` : "The freelancer will see that this change is not approved."}

`; + document.querySelector("#confirm-decision").focus(); + document.querySelector("#confirm-decision").addEventListener("click", () => finalizeDecision(receipt, decision)); + document.querySelector("#cancel-decision").addEventListener("click", () => showReceipt(receipt)); +} + +function finalizeDecision(receipt, decision) { + const finalized = { ...receipt, decision, decidedAt: new Date().toISOString() }; + const link = linkFor(finalized); + history.replaceState({}, "", `#r=${encodeReceipt(finalized)}`); + app.innerHTML = `${wordmark()}

DECISION LINK COPY CREATED

${decision === "approved" ? "Approval selected." : "Decline selected."}

${escapeHtml(finalized.project)} · ${formattedTime(finalized.decidedAt)}

${decision === "approved" ? "✓ APPROVAL SELECTED" : "× DECLINE SELECTED"}
${finalReturnPanel(link)}
`; + bindCopies(); + document.querySelector("#select-final-link").addEventListener("click", () => { const input = document.querySelector("#final-link"); input.focus(); input.select(); }); + document.querySelector("#view-receipt").addEventListener("click", () => showFinalReceipt(finalized)); +} + +function showFinalReceipt(receipt) { + app.innerHTML = `${wordmark()}

DECISION LINK COPY

${receipt.decision === "approved" ? "This link says: approved." : "This link says: declined."}

The encoded link includes a selection timestamp of ${formattedTime(receipt.decidedAt)}. It is not signed or independently verified.

${receiptDetail(receipt)}

PORTABLE COPY

${receipt.decision === "approved" ? "Approval selected" : "Decline selected"}

Anyone with the link can alter its encoded data. Confirm the choice in the original conversation before relying on it.

`; +} + +function bindCopies() { + document.querySelectorAll(".copy-button").forEach((button) => button.addEventListener("click", async () => { + try { await navigator.clipboard.writeText(button.dataset.copy); button.textContent = "Copied"; setTimeout(() => { button.textContent = button.dataset.copyLabel || "Copy"; }, 1800); } + catch { button.textContent = "Copy failed — select the link"; } + })); +} + +function renderFromLocation() { + const existing = receiptFromHash(location.hash); + if (existing?.decision && existing.decidedAt) showFinalReceipt(existing); + else if (existing) showReceipt(existing); + else createView(); +} + +window.addEventListener("hashchange", renderFromLocation); +renderFromLocation(); diff --git a/projects/scopefence/auto-company-icon.svg b/projects/scopefence/auto-company-icon.svg new file mode 100644 index 00000000..60854461 --- /dev/null +++ b/projects/scopefence/auto-company-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/projects/scopefence/index.html b/projects/scopefence/index.html new file mode 100644 index 00000000..0fbfdcff --- /dev/null +++ b/projects/scopefence/index.html @@ -0,0 +1,15 @@ + + + + + + + ScopeFence — scope decisions, made clear + + + + +
+ + + diff --git a/projects/scopefence/package.json b/projects/scopefence/package.json new file mode 100644 index 00000000..32e4fb41 --- /dev/null +++ b/projects/scopefence/package.json @@ -0,0 +1,10 @@ +{ + "name": "scopefence", + "private": true, + "version": "1.0.0", + "type": "module", + "license": "MIT", + "scripts": { + "test": "node --test tests/scopefence-core.test.mjs" + } +} diff --git a/projects/scopefence/scopefence-core.js b/projects/scopefence/scopefence-core.js new file mode 100644 index 00000000..3916889f --- /dev/null +++ b/projects/scopefence/scopefence-core.js @@ -0,0 +1,102 @@ +export const SAMPLE_RECEIPT = { + project: "Harbour Studio website", + change: "Add a pricing calculator to the existing landing page.", + price: 450, + days: 3, + context: "The calculator will use the approved service packages and live below the comparison table.", + id: "sample", + sample: true +}; + +const MAX_RECEIPT_ID_LENGTH = 120; +// Covers the bounded form fields even when JSON expands every character to a +// six-character control escape, while still rejecting unusually large input. +const MAX_ENCODED_RECEIPT_LENGTH = 8192; +const FINAL_DECISIONS = new Set(["approved", "declined"]); +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/; + +export function cleanText(value) { + return String(value || "").trim(); +} + +function isBlank(value) { + return value === null || value === undefined || (typeof value === "string" && value.trim() === ""); +} + +export function validateReceipt(receipt) { + const errors = {}; + const project = cleanText(receipt.project); + const change = cleanText(receipt.change); + const context = cleanText(receipt.context); + const price = Number(receipt.price); + const days = Number(receipt.days); + if (project.length < 1 || project.length > 80) errors.project = "Use a project name between 1 and 80 characters."; + if (change.length < 10 || change.length > 500) errors.change = "Describe the change in 10 to 500 characters."; + if (isBlank(receipt.price) || !Number.isInteger(price) || price < 0) errors.price = "Enter a whole-dollar amount of $0 or more."; + if (isBlank(receipt.days) || !Number.isInteger(days) || days < 0 || days > 365) errors.days = "Enter whole days from 0 to 365."; + if (context.length > 300) errors.context = "Keep context to 300 characters or fewer."; + return errors; +} + +function base64UrlEncode(value) { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + let binary = ""; + bytes.forEach((byte) => { binary += String.fromCharCode(byte); }); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function base64UrlDecode(value) { + const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4); + const binary = atob(padded); + return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)))); +} + +export function makeReceipt(input) { + return { + project: cleanText(input.project), + change: cleanText(input.change), + price: Number(input.price), + days: Number(input.days), + context: cleanText(input.context), + id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`, + createdAt: new Date().toISOString() + }; +} + +export function encodeReceipt(receipt) { + return base64UrlEncode(receipt); +} + +export function decodeReceipt(value) { + try { + if (typeof value !== "string" || value.length === 0 || value.length > MAX_ENCODED_RECEIPT_LENGTH) return null; + const receipt = base64UrlDecode(value); + if (Object.keys(validateReceipt(receipt)).length > 0) return null; + if (typeof receipt.id !== "string" || receipt.id.length < 1 || receipt.id.length > MAX_RECEIPT_ID_LENGTH) return null; + + const hasDecision = Object.prototype.hasOwnProperty.call(receipt, "decision"); + const hasDecidedAt = Object.prototype.hasOwnProperty.call(receipt, "decidedAt"); + if (hasDecision !== hasDecidedAt) return null; + if (hasDecision) { + if (!FINAL_DECISIONS.has(receipt.decision)) return null; + if (typeof receipt.decidedAt !== "string" || !ISO_TIMESTAMP.test(receipt.decidedAt) || Number.isNaN(Date.parse(receipt.decidedAt))) return null; + } + + return receipt; + } catch { + return null; + } +} + +export function receiptFromHash(hash) { + const match = String(hash || "").match(/^#r=([^&]+)$/); + return match ? decodeReceipt(match[1]) : null; +} + +export function formatPrice(price) { + return Number(price) === 0 ? "No price increase" : `$${Number(price).toLocaleString("en-US")} USD`; +} + +export function formatDays(days) { + return Number(days) === 0 ? "No timeline change" : `${Number(days)} day${Number(days) === 1 ? "" : "s"} added`; +} diff --git a/projects/scopefence/styles.css b/projects/scopefence/styles.css new file mode 100644 index 00000000..d9c9a4dd --- /dev/null +++ b/projects/scopefence/styles.css @@ -0,0 +1,19 @@ +:root { --paper:#f4f0e6; --surface:#fffdf7; --ink:#17231e; --muted:#55625a; --fence:#d75a31; --fence-dark:#a93d20; --inside:#dce9d4; --outside:#f5ddcb; --focus:#005fcc; --error:#a72626; } +* { box-sizing:border-box; } +body { margin:0; color:var(--ink); background-color:var(--paper); background-image:radial-gradient(rgba(23,35,30,.10) .65px, transparent .75px); background-size:7px 7px; font-family:"IBM Plex Sans","Trebuchet MS",sans-serif; font-size:16px; line-height:1.5; } +button,input,textarea { font:inherit; } +button { cursor:pointer; } +button:focus-visible,input:focus-visible,textarea:focus-visible,a:focus-visible { outline:3px solid var(--focus); outline-offset:3px; } +.page-shell { width:min(1120px,100% - 40px); margin:auto; padding-bottom:72px; } +.site-header { height:72px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid rgba(23,35,30,.22); } +.brand { color:var(--ink); text-decoration:none; display:flex; align-items:center; gap:9px; font-family:"DM Serif Display",Georgia,serif; font-size:25px; letter-spacing:-.03em; }.brand img { width:23px; height:23px; }.draft-state,.eyebrow { margin:0; font-family:"IBM Plex Mono",Consolas,monospace; font-size:12px; letter-spacing:.075em; font-weight:700; }.draft-state { color:var(--muted); font-size:10px; } +.hero { padding:68px 0 40px; }.hero h1,.final-state h1 { max-width:780px; margin:12px 0 16px; font-family:"DM Serif Display",Georgia,serif; font-weight:400; font-size:clamp(39px,5.2vw,66px); line-height:.98; letter-spacing:-.045em; }.lede { max-width:580px; margin:0; color:var(--muted); font-size:19px; } +.creator-grid { display:grid; grid-template-columns:7fr 5fr; align-items:stretch; border-top:3px solid var(--ink); }.creator-grid form { padding:32px 36px 38px 0; }.form-heading { margin-bottom:30px; }.form-heading p:last-child { color:var(--muted); margin:8px 0 0; } +label { display:block; font-weight:700; margin-top:18px; } input,textarea { display:block; width:100%; margin-top:7px; padding:12px; color:var(--ink); background:var(--surface); border:2px solid var(--ink); border-radius:2px; } textarea { resize:vertical; min-height:88px; } input:focus,textarea:focus { border-color:var(--fence-dark); }.impact-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; }.help,.error { margin:5px 0 0; font-size:14px; }.help { color:var(--muted); }.error { color:var(--error); min-height:21px; } +.primary,.secondary,.decision { min-height:48px; border:2px solid var(--ink); border-radius:2px; padding:10px 16px; font-weight:700; transition:transform .16s ease-out,background .16s ease-out; }.primary { margin-top:18px; color:#fffdf7; background:var(--fence); }.primary:hover { background:var(--fence-dark); transform:translateY(-1px); }.secondary { color:var(--ink); background:var(--surface); }.secondary:hover,.decision:hover { background:#ebe5d8; }.text-button { display:block; padding:12px 0 0; color:var(--ink); background:none; border:0; border-bottom:1px solid currentColor; font-weight:700; }.draft-note { position:relative; overflow:hidden; padding:34px; background:var(--inside); border-left:3px solid var(--ink); }.draft-note p:nth-child(2) { max-width:290px; margin:12px 0; font-family:"DM Serif Display",Georgia,serif; font-size:25px; line-height:1.12; }.corner-mark { position:absolute; width:112px; height:112px; right:-25px; bottom:-25px; border:18px solid var(--fence); border-left:0; border-top:0; } +.receipt-hero { padding-bottom:28px; }.receipt-wrap { border-top:3px solid var(--ink); }.receipt-details { display:grid; grid-template-columns:1fr 24px 1fr; }.territory { min-height:254px; padding:28px; }.territory h2,.decision-panel h2,.share-panel h2 { font-family:"DM Serif Display",Georgia,serif; font-size:29px; line-height:1.05; font-weight:400; margin:12px 0; }.inside { background:var(--inside); }.outside { background:var(--outside); }.context { color:var(--muted); border-top:1px solid rgba(23,35,30,.35); padding-top:14px; }.fence-rule { display:flex; align-items:center; justify-content:center; background:repeating-linear-gradient(to bottom,var(--fence) 0 10px,transparent 10px 18px); }.fence-rule span { writing-mode:vertical-rl; background:var(--paper); padding:10px 3px; color:var(--fence-dark); font-family:"IBM Plex Mono",Consolas,monospace; font-size:10px; letter-spacing:.08em; font-weight:700; }dl { margin:26px 0 0; }dl div { padding:12px 0; border-top:1px solid rgba(23,35,30,.35); }dt { font-size:13px; color:var(--muted); }dd { margin:3px 0 0; font-family:"IBM Plex Mono",Consolas,monospace; font-size:18px; font-weight:700; } +.decision-panel { padding:32px; background:var(--surface); border-top:3px solid var(--ink); }.decision-panel > p:not(.eyebrow) { max-width:650px; color:var(--muted); }.decision-actions { display:flex; gap:12px; flex-wrap:wrap; margin-top:24px; }.decision { flex:1 1 220px; display:flex; justify-content:space-between; align-items:center; color:var(--ink); background:var(--surface); text-align:left; }.approve { border-top:7px solid #56814b; }.decline { border-top:7px solid var(--fence); }.locked { background:#e9e6dd; }.share-panel { display:grid; grid-template-columns:1fr auto; gap:24px; align-items:center; margin-top:24px; padding:28px; color:var(--surface); background:var(--ink); box-shadow:0 12px 28px rgba(23,35,30,.14); }.share-panel h2 { font-size:28px; }.link-value { overflow-wrap:anywhere; font-family:"IBM Plex Mono",Consolas,monospace; font-size:12px; color:#dce9d4; }.copy-actions { display:flex; gap:9px; flex-wrap:wrap; }.copy-actions .primary { margin:0; }.copy-actions .secondary { color:var(--surface); background:transparent; border-color:var(--surface); }.share-note { grid-column:1/-1; margin:0; color:#c5d0c9; font-size:14px; }.final-state { max-width:760px; padding:96px 0; }.final-state p { max-width:560px; color:var(--muted); }.final-stamp { display:inline-block; margin:18px 0; padding:15px 20px; border:3px solid currentColor; font-family:"IBM Plex Mono",Consolas,monospace; font-weight:700; font-size:21px; transform:rotate(-2deg); }.final-state.approved .final-stamp { color:#3f7037; }.final-state.declined .final-stamp { color:var(--fence-dark); } +.return-panel { margin:28px 0 18px; padding:24px; color:var(--surface); background:var(--ink); box-shadow:0 12px 28px rgba(23,35,30,.14); }.return-panel h2 { margin:10px 0; font-family:"DM Serif Display",Georgia,serif; font-size:29px; line-height:1.05; font-weight:400; }.return-panel > p:not(.eyebrow) { color:#c5d0c9; }.final-link-label { margin-top:18px; color:var(--surface); font-size:14px; }.final-link-input { margin-top:7px; color:var(--ink); background:var(--surface); font-family:"IBM Plex Mono",Consolas,monospace; font-size:12px; font-weight:400; }.return-panel .copy-actions { margin-top:16px; }.return-panel .primary { margin:0; }.return-panel .secondary { color:var(--surface); background:transparent; border-color:var(--surface); } +@media (max-width:720px) { .page-shell { width:min(100% - 40px,640px); }.site-header { height:60px; }.draft-state { display:none; }.hero { padding:46px 0 28px; }.creator-grid { grid-template-columns:1fr; }.creator-grid form { padding:28px 0; }.draft-note { border-left:0; border-top:3px solid var(--ink); }.receipt-details { grid-template-columns:1fr; }.territory { min-height:0; padding:24px; }.fence-rule { height:22px; background:repeating-linear-gradient(to right,var(--fence) 0 10px,transparent 10px 18px); }.fence-rule span { writing-mode:initial; }.share-panel { grid-template-columns:1fr; }.copy-actions { display:grid; grid-template-columns:1fr 1fr; }.decision-actions { flex-direction:column; }.decision { width:100%; flex-basis:auto; }.impact-row { grid-template-columns:1fr; gap:0; } } +@media (max-width:420px) { .page-shell { width:min(100% - 32px,640px); }.hero h1,.final-state h1 { font-size:39px; }.lede { font-size:17px; }.copy-actions { grid-template-columns:1fr; }.territory,.decision-panel,.share-panel { padding:20px; } } +@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto !important; transition-duration:.01ms !important; animation-duration:.01ms !important; } } diff --git a/projects/scopefence/tests/scopefence-core.test.mjs b/projects/scopefence/tests/scopefence-core.test.mjs new file mode 100644 index 00000000..64972e1a --- /dev/null +++ b/projects/scopefence/tests/scopefence-core.test.mjs @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { decodeReceipt, encodeReceipt, formatDays, formatPrice, makeReceipt, receiptFromHash, validateReceipt } from "../scopefence-core.js"; + +const receipt = { project: "Élan web", change: "Add an accessibility audit & summary.", price: 0, days: 1, context: "客户确认", id: "test-001" }; + +test("validates the required receipt boundaries", () => { + assert.deepEqual(validateReceipt(receipt), {}); + assert.ok(validateReceipt({ ...receipt, project: "" }).project); + assert.ok(validateReceipt({ ...receipt, change: "short" }).change); + assert.ok(validateReceipt({ ...receipt, price: "" }).price); + assert.ok(validateReceipt({ ...receipt, price: "2.5" }).price); + assert.ok(validateReceipt({ ...receipt, days: "" }).days); + assert.ok(validateReceipt({ ...receipt, days: "366" }).days); +}); + +test("round-trips Unicode receipt content through a shareable hash", () => { + const encoded = encodeReceipt(receipt); + assert.deepEqual(decodeReceipt(encoded), receipt); + assert.deepEqual(receiptFromHash(`#r=${encoded}`), receipt); +}); + +test("round-trips maximum form fields after worst-case JSON escaping", () => { + const input = { + project: "\u0001".repeat(80), + change: "\u0001".repeat(500), + price: "0", + days: "0", + context: "\u0001".repeat(300) + }; + assert.deepEqual(validateReceipt(input), {}); + const made = makeReceipt(input); + const encoded = encodeReceipt(made); + assert.ok(encoded.length > 4096); + assert.ok(encoded.length <= 8192); + assert.deepEqual(decodeReceipt(encoded), made); + const finalized = { ...made, decision: "approved", decidedAt: "2026-09-19T11:00:00.000Z" }; + const finalizedEncoded = encodeReceipt(finalized); + assert.ok(finalizedEncoded.length <= 8192); + assert.deepEqual(decodeReceipt(finalizedEncoded), finalized); +}); + +test("preserves a final decision in the returned receipt link", () => { + const finalized = { ...receipt, decision: "approved", decidedAt: "2026-09-19T11:00:00.000Z" }; + const encoded = encodeReceipt(finalized); + assert.deepEqual(receiptFromHash(`#r=${encoded}`), finalized); +}); + +test("rejects malformed receipt links safely", () => { + assert.equal(decodeReceipt("not-a-receipt"), null); + assert.equal(decodeReceipt("x".repeat(8193)), null); + assert.equal(receiptFromHash("#r=broken&extra"), null); + assert.equal(receiptFromHash("#nothing"), null); +}); + +test("rejects incomplete or invalid final decision payloads", () => { + assert.equal(decodeReceipt(encodeReceipt({ ...receipt, decision: "maybe", decidedAt: "2026-09-19T11:00:00.000Z" })), null); + assert.equal(decodeReceipt(encodeReceipt({ ...receipt, decision: "approved" })), null); + assert.equal(decodeReceipt(encodeReceipt({ ...receipt, decidedAt: "2026-09-19T11:00:00.000Z" })), null); + assert.equal(decodeReceipt(encodeReceipt({ ...receipt, decision: "declined", decidedAt: "not-a-date" })), null); + assert.equal(decodeReceipt(encodeReceipt({ ...receipt, id: "x".repeat(121) })), null); +}); + +test("accepts only the two supported final decisions with valid timestamps", () => { + for (const decision of ["approved", "declined"]) { + const finalized = { ...receipt, decision, decidedAt: "2026-09-19T11:00:00.000Z" }; + assert.deepEqual(decodeReceipt(encodeReceipt(finalized)), finalized); + } +}); + +test("formats zero and singular impacts without ambiguity", () => { + assert.equal(formatPrice(0), "No price increase"); + assert.equal(formatPrice(450), "$450 USD"); + assert.equal(formatDays(0), "No timeline change"); + assert.equal(formatDays(1), "1 day added"); + assert.equal(formatDays(2), "2 days added"); +}); diff --git a/projects/text-meter/.gitignore b/projects/text-meter/.gitignore new file mode 100644 index 00000000..bf9732e8 --- /dev/null +++ b/projects/text-meter/.gitignore @@ -0,0 +1,6 @@ +# Local secrets and generated output +.env +.env.* +dist/ +build/ +.auto-company/ diff --git a/projects/text-meter/README.md b/projects/text-meter/README.md new file mode 100644 index 00000000..9844f66a --- /dev/null +++ b/projects/text-meter/README.md @@ -0,0 +1,35 @@ +# Text Meter + +Text Meter is a private, dependency-free browser tool for checking text as you write. It updates character count, non-whitespace character count, word count, and line count immediately in the browser. + +## Run locally + +Because the page uses JavaScript modules, serve this directory over loopback HTTP instead of opening `index.html` through a `file://` URL: + +```sh +python3 -m http.server 8000 --bind 127.0.0.1 +``` + +On Windows, use `py` instead of `python3`. Then open . + +Type or paste text into the editor. The four counters update on every input. + +- **Clear text** empties the editor and returns focus to it. +- **Use a sample** inserts a short example so the behavior is easy to inspect. +- No text is stored, transmitted, or saved by the product. + +## Counting rules + +Characters are counted as Unicode code points. Non-whitespace characters exclude JavaScript whitespace. Words are non-empty runs separated by whitespace, so text without spaces, including a Chinese phrase, counts as one word. Lines are separated by `LF`, `CRLF`, or `CR`; empty text has zero lines. + +## Test + +The product has no runtime or test dependencies. With a current Node.js release installed: + +```sh +npm test +``` + +## License and asset source + +This snapshot is covered by the repository's [MIT license](../../LICENSE). The geometric SVG is a program-generated fallback icon from Auto Company's product-media flow. No external image, font file, script, style, analytics service, or other remote asset is bundled. See [source history](SOURCE.md) for its requested-product origin. diff --git a/projects/text-meter/SOURCE.md b/projects/text-meter/SOURCE.md new file mode 100644 index 00000000..3b0dabc1 --- /dev/null +++ b/projects/text-meter/SOURCE.md @@ -0,0 +1,7 @@ +# Source note + +This source snapshot was prepared from the icon-corrected Text Meter implementation accepted for an ordinary requested Text Meter product. The run completed two product cycles across two process starts. This provenance does not claim autonomous product selection. + +The snapshot preserves the accepted application, visual design, counting logic, default icon, package metadata, and complete test suite. Publication changes are limited to reliable static-server instructions, public license and asset notes, and this source note. + +The copy excludes the nested Git repository, Auto Company project identity and check state, internal delivery records, model records, logs, memories, and local runtime files. No dependency, remote resource, analytics, or network service was added. diff --git a/projects/text-meter/app.js b/projects/text-meter/app.js new file mode 100644 index 00000000..15f9afe0 --- /dev/null +++ b/projects/text-meter/app.js @@ -0,0 +1,38 @@ +import { countText } from './counts.js'; + +const sample = `A short note can carry a lot. + +Text Meter keeps the numbers out of your way until you need them.`; + +const input = document.querySelector('#text-input'); +const emptyState = document.querySelector('#empty-state'); +const metrics = { + characters: document.querySelector('#character-count'), + nonWhitespaceCharacters: document.querySelector('#non-whitespace-count'), + words: document.querySelector('#word-count'), + lines: document.querySelector('#line-count'), +}; + +function updateCounts() { + const counts = countText(input.value); + Object.entries(metrics).forEach(([key, element]) => { + element.textContent = counts[key].toLocaleString(); + }); + emptyState.hidden = input.value.length > 0; +} + +document.querySelector('#clear-button').addEventListener('click', () => { + input.value = ''; + updateCounts(); + input.focus(); +}); + +document.querySelector('#sample-button').addEventListener('click', () => { + input.value = sample; + updateCounts(); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); +}); + +input.addEventListener('input', updateCounts); +updateCounts(); diff --git a/projects/text-meter/auto-company-icon.svg b/projects/text-meter/auto-company-icon.svg new file mode 100644 index 00000000..9ad226d9 --- /dev/null +++ b/projects/text-meter/auto-company-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/projects/text-meter/counts.js b/projects/text-meter/counts.js new file mode 100644 index 00000000..e9f6f010 --- /dev/null +++ b/projects/text-meter/counts.js @@ -0,0 +1,16 @@ +/** + * Return the four counts shown by Text Meter. + * Characters are Unicode code points; words are non-empty, whitespace-separated runs. + */ +export function countText(value) { + const text = String(value ?? ''); + const characters = Array.from(text); + const hasText = text.length > 0; + + return { + characters: characters.length, + nonWhitespaceCharacters: characters.filter((character) => !/\s/u.test(character)).length, + words: text.trim() ? text.trim().split(/\s+/u).length : 0, + lines: hasText ? text.split(/\r\n|\r|\n/u).length : 0, + }; +} diff --git a/projects/text-meter/index.html b/projects/text-meter/index.html new file mode 100644 index 00000000..36c527a9 --- /dev/null +++ b/projects/text-meter/index.html @@ -0,0 +1,62 @@ + + + + + + + + + Text Meter — Count what matters + + +
+
+ + + Text Meter + +

Local only. Nothing is saved.

+
+ +
+

A small instrument for writing

+

Count what
matters.

+

Paste a draft, shape a sentence, or check a limit. The figures update with every keystroke.

+
+ +
+
+ + +

There is nothing to count yet.

+
+ + +
+
+ +
+
+
Characters
+
0
+
+
+
Without spaces
+
0
+
+
+
Words
+
0
+
+
+
Lines
+
0
+
+
+
+ +
Text Meter counts Unicode characters and whitespace-separated words.
+
+ + + diff --git a/projects/text-meter/package.json b/projects/text-meter/package.json new file mode 100644 index 00000000..f14bb634 --- /dev/null +++ b/projects/text-meter/package.json @@ -0,0 +1,9 @@ +{ + "name": "text-meter", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "test": "node --test tests/counts.test.js" + } +} diff --git a/projects/text-meter/styles.css b/projects/text-meter/styles.css new file mode 100644 index 00000000..1f953dc6 --- /dev/null +++ b/projects/text-meter/styles.css @@ -0,0 +1,75 @@ +:root { + --ink: #1a1b18; + --paper: #f3efe5; + --paper-deep: #e5dece; + --rule: #c6bcaa; + --signal: #b4482e; + --muted: #716d64; + font-family: Georgia, 'Times New Roman', serif; + color: var(--ink); + background: var(--paper); +} + +* { box-sizing: border-box; } + +body { + min-width: 320px; + margin: 0; + background: + linear-gradient(90deg, transparent 49.9%, rgba(117, 103, 75, .055) 50%, transparent 50.1%), + var(--paper); +} + +button, textarea { font: inherit; } + +.shell { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 24px; } + +.masthead { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--rule); padding-bottom: 22px; } +.brand { align-items: center; color: inherit; display: inline-flex; font-size: 1.12rem; font-weight: bold; gap: 9px; letter-spacing: -.03em; text-decoration: none; } +.brand img { display: block; } +.privacy-note { color: var(--muted); font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; font-size: .7rem; letter-spacing: .04em; margin: 0; text-transform: uppercase; } +.privacy-note span { color: #568156; font-size: .8rem; vertical-align: 1px; } + +.intro { max-width: 665px; padding: clamp(58px, 11vw, 128px) 0 clamp(44px, 7vw, 78px); } +.eyebrow { color: var(--signal); font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; font-size: .72rem; font-weight: bold; letter-spacing: .1em; margin: 0 0 15px; text-transform: uppercase; } +h1 { font-size: clamp(3.7rem, 9vw, 7.8rem); font-weight: 400; letter-spacing: -.075em; line-height: .84; margin: 0; } +h1 em { color: var(--signal); font-weight: 400; } +.lede { color: #4c4a43; font-size: clamp(1rem, 1.6vw, 1.2rem); line-height: 1.5; margin: 28px 0 0; max-width: 460px; } + +.workbench { align-items: stretch; border-top: 3px solid var(--ink); display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(295px, .85fr); } +.editor-card { border: 1px solid var(--rule); border-top: 0; padding: 22px; position: relative; } +label, .metric dt { font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; font-size: .69rem; font-weight: bold; letter-spacing: .09em; text-transform: uppercase; } +textarea { background: transparent; border: 0; border-radius: 0; color: var(--ink); display: block; font-size: 1.15rem; line-height: 1.55; min-height: 270px; outline: none; padding: 17px 0 64px; resize: vertical; width: 100%; } +textarea::placeholder { color: #9d9689; font-style: italic; } +textarea:focus-visible { box-shadow: inset 0 -2px var(--signal); } +.empty-state { color: var(--muted); font-size: .92rem; font-style: italic; left: 22px; margin: 0; pointer-events: none; position: absolute; top: 88px; } +.editor-actions { align-items: center; bottom: 22px; display: flex; gap: 10px; position: absolute; right: 22px; } +.button { border: 1px solid var(--ink); border-radius: 0; cursor: pointer; font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; font-size: .7rem; font-weight: bold; letter-spacing: .04em; min-height: 36px; padding: 0 12px; text-transform: uppercase; transition: background-color 150ms ease, color 150ms ease, transform 150ms ease; } +.button:hover { transform: translateY(-2px); } +.button:focus-visible { outline: 3px solid var(--signal); outline-offset: 3px; } +.button-quiet { background: transparent; color: var(--ink); } +.button-quiet:hover { background: var(--paper-deep); } +.button-filled { background: var(--ink); color: var(--paper); } +.button-filled:hover { background: var(--signal); border-color: var(--signal); } + +.metrics { background: var(--ink); color: var(--paper); display: grid; grid-template-columns: 1fr 1fr; margin: 0; } +.metric { border-bottom: 1px solid #56564e; min-height: 150px; padding: 20px; } +.metric:nth-child(odd) { border-right: 1px solid #56564e; } +.metric:nth-last-child(-n + 2) { border-bottom: 0; } +.metric-primary { background: var(--signal); color: #fff9ed; } +.metric dt { color: inherit; opacity: .78; } +.metric dd { font-size: clamp(2.2rem, 4.6vw, 4rem); letter-spacing: -.075em; line-height: 1; margin: 40px 0 0; } +footer { color: var(--muted); font-size: .78rem; margin-top: 22px; } + +@media (max-width: 700px) { + .shell { width: min(100% - 32px, 580px); } + .masthead { align-items: flex-start; gap: 18px; } + .privacy-note { line-height: 1.35; max-width: 120px; text-align: right; } + .workbench { grid-template-columns: 1fr; } + .editor-card { min-height: 335px; } + textarea { min-height: 235px; } + .metric { min-height: 128px; } + .metric dd { margin-top: 32px; } +} + +@media (prefers-reduced-motion: reduce) { .button { transition: none; } } diff --git a/projects/text-meter/tests/counts.test.js b/projects/text-meter/tests/counts.test.js new file mode 100644 index 00000000..fdfca0b0 --- /dev/null +++ b/projects/text-meter/tests/counts.test.js @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { countText } from '../counts.js'; + +test('counts empty text as zero in every category', () => { + assert.deepEqual(countText(''), { + characters: 0, + nonWhitespaceCharacters: 0, + words: 0, + lines: 0, + }); +}); + +test('counts whitespace while excluding it from non-whitespace characters and words', () => { + assert.deepEqual(countText(' \t '), { + characters: 5, + nonWhitespaceCharacters: 0, + words: 0, + lines: 1, + }); +}); + +test('counts multiple lines including a deliberate blank line', () => { + assert.deepEqual(countText('first\n\nthird'), { + characters: 12, + nonWhitespaceCharacters: 10, + words: 2, + lines: 3, + }); +}); + +test('treats punctuation as characters within whitespace-separated English words', () => { + assert.deepEqual(countText('Hello, world!'), { + characters: 13, + nonWhitespaceCharacters: 12, + words: 2, + lines: 1, + }); +}); + +test('counts Chinese Unicode characters without requiring spaces', () => { + assert.deepEqual(countText('你好,世界'), { + characters: 5, + nonWhitespaceCharacters: 5, + words: 1, + lines: 1, + }); +}); diff --git a/scripts/ci/changes.py b/scripts/ci/changes.py index a08096d4..54a6b722 100644 --- a/scripts/ci/changes.py +++ b/scripts/ci/changes.py @@ -19,7 +19,7 @@ RUNTIME_PREFIXES = (".claude/", "dashboard/", "i18n/", "memories/", "scripts/", "tests/") BROWSER_PREFIXES = ( "dashboard/", "i18n/", "scripts/core/", "scripts/windows/", "scripts/macos/", - "scripts/wsl/", "tests/browser/", "tests/fixtures/", + "scripts/wsl/", "scripts/media/", "tests/browser/", "tests/fixtures/", ) @@ -38,7 +38,7 @@ def route_paths(paths): runtime = path in RUNTIME_FILES or path.startswith(RUNTIME_PREFIXES) or path.endswith(".sh") if runtime: selected["runtime"] = True - if path.startswith(BROWSER_PREFIXES) or path.startswith("tests/test_dashboard"): + if path.startswith(BROWSER_PREFIXES) or path.startswith("tests/test_dashboard") or path == "tests/test_product_media.py": selected["browser"] = True if product or runtime: continue diff --git a/scripts/core/auto-loop.sh b/scripts/core/auto-loop.sh index 9a3a63f9..4c358065 100755 --- a/scripts/core/auto-loop.sh +++ b/scripts/core/auto-loop.sh @@ -64,11 +64,14 @@ STATE_FILE="$PROJECT_DIR/.auto-loop-state" PAUSE_FLAG="$PROJECT_DIR/.auto-loop-paused" CONSENSUS_GUARD="$SCRIPT_DIR/consensus-guard.sh" PROJECT_CONTEXT_TOOL="$SCRIPT_DIR/project-context.py" +PRODUCT_IDENTITY_TOOL="$SCRIPT_DIR/product_identity.py" LOCALIZATION_TOOL="$SCRIPT_DIR/localization.py" USAGE_FILE="$LOG_DIR/usage.jsonl" USAGE_TOOL="$PROJECT_DIR/scripts/core/usage.py" BUDGET_PAUSE_FILE="$PROJECT_DIR/.auto-loop-budget-paused" LOOP_SLEEP_PID="" +CURRENT_PRODUCT_CYCLE_ID="" +MEDIA_FINALIZER_PID="" # Loop settings (all overridable via env vars) ENGINE="${ENGINE:-claude}" @@ -200,6 +203,9 @@ STATUS=$status PAUSE_REASON=$pause_reason MODEL=$MODEL_LABEL ENGINE=$ENGINE +PRODUCT_ID=${PRODUCT_ID:-} +PRODUCT_CYCLE_NUMBER=${PRODUCT_CYCLE_NUMBER:-} +PRODUCT_CYCLE_ID=$CURRENT_PRODUCT_CYCLE_ID EOF } @@ -248,6 +254,32 @@ record_cycle_usage() { echo "record_error" } +cancel_media_finalizer() { + if [ -n "$MEDIA_FINALIZER_PID" ]; then + kill -TERM "$MEDIA_FINALIZER_PID" 2>/dev/null || true + wait "$MEDIA_FINALIZER_PID" 2>/dev/null || true + MEDIA_FINALIZER_PID="" + fi +} + +finalize_cycle_media() { + [ ! -f "$PROJECT_DIR/.auto-loop-stop" ] || return 0 + python3 "$SCRIPT_DIR/runtime_artifacts.py" --root "$PROJECT_DIR" finalize \ + --cycle "$CURRENT_PRODUCT_CYCLE_ID" >/dev/null 2>&1 & + MEDIA_FINALIZER_PID=$! + # Waiting on a tracked background process lets TERM run cleanup immediately. + # A plain stop flag also cancels optional media while retaining cycle facts. + while kill -0 "$MEDIA_FINALIZER_PID" 2>/dev/null; do + if [ -f "$PROJECT_DIR/.auto-loop-stop" ]; then + cancel_media_finalizer + return 0 + fi + loop_sleep 0.1 + done + wait "$MEDIA_FINALIZER_PID" 2>/dev/null || true + MEDIA_FINALIZER_PID="" +} + cleanup() { local requested_exit="${1:-0}" final_state="${2:-stopped}" trap '' SIGTERM SIGINT SIGHUP @@ -256,6 +288,7 @@ cleanup() { wait "$LOOP_SLEEP_PID" 2>/dev/null || true LOOP_SLEEP_PID="" fi + cancel_media_finalizer if ! cycle_supervisor_cleanup; then requested_exit=1 final_state="process_cleanup_failed" @@ -263,7 +296,7 @@ cleanup() { else if [ -n "${AUTO_COMPANY_CYCLE_ID:-}" ]; then python3 "$SCRIPT_DIR/runtime_artifacts.py" --root "$PROJECT_DIR" finalize \ - --cycle "$AUTO_COMPANY_CYCLE_ID" >/dev/null 2>&1 || true + --cycle "$AUTO_COMPANY_CYCLE_ID" --cleanup-only >/dev/null 2>&1 || true fi # A signal interrupts adapter_execute before it can publish its output. # Preserve already emitted evidence after the owned process tree exits. @@ -288,6 +321,9 @@ cleanup() { "$CONSENSUS_GUARD" recover || true fi python3 "$USAGE_TOOL" recover --ledger "$USAGE_FILE" --pause-file "$BUDGET_PAUSE_FILE" || log "Interrupted usage recovery failed; pending identity retained for restart" + if [ "$CYCLE_SUPERVISOR_CLEANUP_FAILED" -eq 0 ]; then + python3 "$PRODUCT_IDENTITY_TOOL" --root "$PROJECT_DIR" recover >/dev/null || log "Product cycle recovery failed; reservation retained for restart" + fi log "=== Auto Loop Shutting Down (PID $$) ===" log "$(ui_message loop.stopping)" # Keep the inode: unlinking a held lock lets another process lock a new file. @@ -467,11 +503,15 @@ run_engine_cycle() { export ACTIVE_PROJECT ACTIVE_PROJECT_PATH python3 "$SCRIPT_DIR/runtime_artifacts.py" --root "$PROJECT_DIR" --project "$ACTIVE_PROJECT" context \ --cycle "$AUTO_COMPANY_CYCLE_ID" >/dev/null 2>&1 || true + if ! python3 "$PRODUCT_IDENTITY_TOOL" --root "$PROJECT_DIR" update \ + --cycle "$AUTO_COMPANY_CYCLE_ID" --state dispatching >/dev/null; then + cleanup 1 product_cycle_state_error + fi engine_adapter_run "$prompt" # Observation cleanup does not classify the cycle or replace supervision. if [ "$CYCLE_SUPERVISOR_CLEANUP_FAILED" -eq 0 ]; then python3 "$SCRIPT_DIR/runtime_artifacts.py" --root "$PROJECT_DIR" finalize \ - --cycle "$AUTO_COMPANY_CYCLE_ID" >/dev/null 2>&1 || true + --cycle "$AUTO_COMPANY_CYCLE_ID" --cleanup-only >/dev/null 2>&1 || true fi unset AUTO_COMPANY_CYCLE unset AUTO_COMPANY_CYCLE_ID @@ -520,6 +560,10 @@ if [ "$governance_recovery_status" -ne 0 ] && [ "$governance_recovery_status" -n exit 1 fi "$CONSENSUS_GUARD" init +if ! python3 "$PRODUCT_IDENTITY_TOOL" --root "$PROJECT_DIR" recover >/dev/null; then + echo "Error: unfinished product cycle recovery failed; no new cycle was started." + exit 1 +fi # Recover human-owned configuration before interpreting its language setting. if ! python3 "$LOCALIZATION_TOOL" check --root "$PROJECT_DIR" >/dev/null; then @@ -624,8 +668,19 @@ while true; do continue fi ACTIVE_PROJECT="" + PROJECT_CONTEXT_SOURCE="human selection" if [ -n "$ACTIVE_PROJECT_PATH" ]; then ACTIVE_PROJECT="projects/${ACTIVE_PROJECT_PATH##*/}" + else + # Creation links exploration to a product without editing human selection. + if ! ACTIVE_PROJECT=$(python3 "$PRODUCT_IDENTITY_TOOL" --root "$PROJECT_DIR" continuation) || + { [ -n "$ACTIVE_PROJECT" ] && ! ACTIVE_PROJECT_PATH=$(python3 "$PROJECT_CONTEXT_TOOL" validate --root "$PROJECT_DIR" --project "$ACTIVE_PROJECT"); }; then + log_cycle "$next_cycle" "FAIL" "Registered product continuation is invalid; engine invocation blocked" + printf 'PAUSE_REASON=product_continuation_invalid\n' > "$PAUSE_FLAG" + wait_while_paused + continue + fi + PROJECT_CONTEXT_SOURCE="registered exploration continuation" fi # Pin once for the whole product, including later iterations and restarts. @@ -640,12 +695,8 @@ while true; do export AUTO_COMPANY_LANGUAGE loop_count=$next_cycle - cycle_log="$LOG_DIR/cycle-$(printf '%04d' "$loop_count")-$(date '+%Y%m%d-%H%M%S')-${run_id}.log" cycle_started_at=$(date '+%Y-%m-%dT%H:%M:%S%z') - log_cycle "$loop_count" "START" "Beginning work cycle" - save_state "running" - # Log rotation rotate_logs @@ -658,6 +709,19 @@ while true; do # Optional observation instructions must never block the existing cycle. REPORT_INSTRUCTIONS=$(python3 "$SCRIPT_DIR/cycle_reports.py" prompt 2>/dev/null) || REPORT_INSTRUCTIONS="" ARTIFACT_INSTRUCTIONS=$(python3 "$SCRIPT_DIR/runtime_artifacts.py" prompt 2>/dev/null) || ARTIFACT_INSTRUCTIONS="" + # All preflight checks passed. Allocate identity, number and pending record + # atomically immediately before invoking the provider; logs never count work. + if ! cycle_reservation=$(python3 "$PRODUCT_IDENTITY_TOOL" --root "$PROJECT_DIR" --project "$ACTIVE_PROJECT" reserve \ + --attempt "${run_id}-${loop_count}" --run-cycle "$loop_count" --engine "$ENGINE" --model "$MODEL_LABEL" --shell); then + log_cycle "$loop_count" "FAIL" "Cannot reserve durable product cycle identity" + cleanup 1 product_cycle_state_error + fi + read -r CURRENT_PRODUCT_CYCLE_ID PRODUCT_ID PRODUCT_CYCLE_NUMBER PRODUCT_IDENTITY_KIND <<< "$cycle_reservation" + cycle_log="$LOG_DIR/${CURRENT_PRODUCT_CYCLE_ID}.log" + export AUTO_COMPANY_STABLE_PRODUCT_ID="$PRODUCT_ID" + export AUTO_COMPANY_PRODUCT_CYCLE_NUMBER="$PRODUCT_CYCLE_NUMBER" + log_cycle "$loop_count" "START" "Beginning $PRODUCT_IDENTITY_KIND cycle #$PRODUCT_CYCLE_NUMBER" + save_state "running" FULL_PROMPT="$PROMPT --- @@ -677,7 +741,7 @@ while true; do - Framework working directory: \`$PROJECT_DIR\` - Consensus baton: \`$CONSENSUS_FILE\` -- Human-selected ACTIVE_PROJECT: \`${ACTIVE_PROJECT:-none (framework exploration)}\` +- Bound ACTIVE_PROJECT: \`${ACTIVE_PROJECT:-none (framework exploration)}\` (source: $PROJECT_CONTEXT_SOURCE) - Selected product repository: \`${ACTIVE_PROJECT_PATH:-none}\` - If a project is selected, perform all product source work there and use \`git -C \"$ACTIVE_PROJECT_PATH\"\` for product Git operations. Keep product commits and remotes out of the framework repository. - Framework cwd remains available for company coordination and consensus. Project selection is workflow routing, not an OS filesystem or network sandbox. @@ -694,7 +758,9 @@ $CONSENSUS --- -This is Cycle #$loop_count. Act decisively." +This is $PRODUCT_IDENTITY_KIND cycle #$PRODUCT_CYCLE_NUMBER (process-local attempt #$loop_count). +The stable runtime identity is $PRODUCT_ID. Process restarts and model changes do not restart product discovery. +Resume the work and phase in the current consensus. First-exploration rules apply only when this is exploration cycle #1 and no earlier decision is recorded. Act decisively." # Run selected engine in headless mode with per-cycle timeout if ! python3 "$USAGE_TOOL" begin --ledger "$USAGE_FILE" \ @@ -812,6 +878,14 @@ This is Cycle #$loop_count. Act decisively." fi budget_state=$(record_cycle_usage) + if ! python3 "$PRODUCT_IDENTITY_TOOL" --root "$PROJECT_DIR" update \ + --cycle "$CURRENT_PRODUCT_CYCLE_ID" --state "$CYCLE_LEDGER_STATUS" --reason "$cycle_failed_reason" >/dev/null; then + log_cycle "$loop_count" "FAIL" "Cannot close the durable product cycle; refusing another cycle" + cleanup 1 product_cycle_state_error + fi + # Seal the AI result before optional media: stopping a slow capture must not + # discard output or relabel an already completed provider invocation. + finalize_cycle_media case "$budget_state" in warning) log_cycle "$loop_count" "BUDGET" "Warning threshold reached; next cycle remains enabled" diff --git a/scripts/core/product_icon_html.py b/scripts/core/product_icon_html.py new file mode 100644 index 00000000..e296f116 --- /dev/null +++ b/scripts/core/product_icon_html.py @@ -0,0 +1,232 @@ +"""Narrow static-HTML favicon linkage; never rewrite or synthesize business DOM.""" +from __future__ import annotations + +from html.parser import HTMLParser +from pathlib import PurePosixPath +import re +from urllib.parse import unquote, urljoin, urlsplit + + +class HeadReferences(HTMLParser): + # HTMLParser otherwise tokenizes apparent tags inside title RCDATA. For + # boundary detection, treating title like raw text is sufficient: original + # bytes are retained and its character references are never rewritten. + CDATA_CONTENT_ELEMENTS = ("script", "style", "title") + + def __init__(self, text): + super().__init__(convert_charrefs=True) + self.source = text + # HTMLParser counts only LF characters for getpos(), not Python's wider + # splitlines() set (CR, NEL and Unicode line/paragraph separators). + self.offsets = [0, *(index + 1 for index, character in enumerate(text) if character == "\n")] + self.heads = self.ends = self.templates = 0 + self.in_head = False + self.invalid = False + self.ambiguous = False + self.base = False + self.raw_text = None + self.close_offset = None + self.links = [] + self.owned = [] + + def handle_starttag(self, tag, attrs): + if self.raw_text: + self.invalid = True + return + attributes = dict(attrs) + if self.heads == 0 and tag not in {"html", "head"}: + self.invalid = True + if self.in_head and not self.templates and tag not in {"head", "title", "base", "link", "meta", "style", "script", "noscript", "template"}: + self.invalid = True + if self.in_head and tag in {"base", "link"} and len(attributes) != len(attrs): + self.ambiguous = True + if tag == "head": + self.heads += 1 + self.in_head = True + elif tag == "body" and self.in_head: + self.invalid = True + elif self.in_head and tag in {"template", "noscript"}: + # These require additional browser insertion modes or scripting + # state. They are outside the deliberately narrow supported head. + self.invalid = True + self.templates += 1 + elif self.in_head and not self.templates: + if tag in {"title", "style", "script"}: + self.raw_text = tag + if tag == "base" and attributes.get("href") is not None: + self.base = True + if tag == "link": + if attributes.get("data-auto-company-icon") is not None: + self.owned.append(attributes) + if "icon" in (attributes.get("rel") or "").lower().split(): + self.links.append(attributes) + + def handle_startendtag(self, tag, attrs): + if tag in {"head", "template", "noscript", *self.CDATA_CONTENT_ELEMENTS}: + self.invalid = True + self.handle_starttag(tag, attrs) + if tag in {"head", "template", "noscript"}: + self.handle_endtag(tag) + + def handle_endtag(self, tag): + if self.in_head or self.raw_text or tag == "head": + line, column = self.getpos() + offset = self.offsets[line - 1] + column + end = self.source.find(">", offset) + token = self.source[offset:end + 1] if end >= 0 else "" + # HTMLParser accepts spellings such as and + # which browsers do not use to close these elements. A conventional + # exact end token is the only supported boundary. + if not re.fullmatch(r"", token, re.IGNORECASE): + self.invalid = True + if self.raw_text: + if tag == self.raw_text: + self.raw_text = None + else: + self.invalid = True + return + if self.heads == 0: + self.invalid = True + if self.in_head and not self.templates and tag != "head": + # In particular
, and may end the browser head + # before our closing token. Unknown end tags are also fail-closed. + self.invalid = True + return + if tag in {"template", "noscript"} and self.in_head: + self.templates = max(0, self.templates - 1) + elif tag == "head": + self.ends += 1 + if not self.in_head or self.templates: + self.invalid = True + self.in_head = False + line, column = self.getpos() + self.close_offset = self.offsets[line - 1] + column + + def handle_data(self, data): + if self.raw_text == "script" and "<" in data: + # Script escaped/double-escaped tokenizer states differ from this + # stdlib parser. Conservatively exclude all script less-than text + # instead of attempting a complete HTML5 script-state parser. + self.invalid = True + if self.getpos() == (1, 0): + data = data.removeprefix("\ufeff") + non_whitespace = data.strip(" \t\n\r\f") + if self.heads == 0 and non_whitespace: + self.invalid = True + if self.in_head and not self.templates and not self.raw_text and non_whitespace: + self.invalid = True + + +def inspect_reference(project, profile, icon, *, write=False): + """Return evidence for the entry's static favicon; only explicit writes insert. + + A source-level reference is not a claim about later JavaScript changes or + browser icon selection. Existing usable custom favicon choices are retained. + """ + from product_media import atomic_bytes, digest, read_bytes, safe_path, validate_icon + + result = {"state": "unsupported", "reason": "static_html_required", "unified": None, "managed": False} + if profile.get("type") != "static": + if profile.get("type") == "none": + result.update(state="not_applicable", reason="nonvisual_product") + elif profile.get("type") == "node": + result.update(state="unconfirmed", reason="dynamic_entry_reference_unconfirmed") + return result + entry = profile.get("entry", "/") + relative = entry.lstrip("/") + if not relative or relative.endswith("/"): + relative += "index.html" + if PurePosixPath(relative).suffix.lower() not in {".html", ".htm"}: + return result + try: + web_root = safe_path(project, profile.get("webRoot", ".")) + target = safe_path(web_root, relative) + raw = read_bytes(target, 2 * 1024 * 1024) + text = raw.decode("utf-8") + result.update(entryPath=target.relative_to(project).as_posix(), entrySha256=digest(raw)) + parser = HeadReferences(text) + parser.feed(text) + parser.close() + if parser.heads != 1 or parser.ends != 1 or parser.invalid or parser.raw_text or parser.close_offset is None: + result.update(state="unsupported", reason="explicit_html_head_required") + return result + if parser.base: + result.update(state="unconfirmed", reason="base_url_reference_unconfirmed") + return result + if parser.ambiguous: + result.update(state="unconfirmed", reason="ambiguous_head_attributes") + return result + canonical_path = safe_path(web_root, "auto-company-icon.svg") + canonical_valid = False + try: + canonical_valid = digest(validate_icon(read_bytes(canonical_path, 32 * 1024))) == icon.get("sha256") + except (OSError, ValueError): + pass + usable = [] + unconfirmed = False + for link in parser.links: + href = link.get("href") or "" + parsed = urlsplit(href) + if parsed.scheme or parsed.netloc or not href or "\\" in href or "\x00" in href: + if href and (parsed.scheme in {"http", "https", "data"} or parsed.netloc): + unconfirmed = True + continue + resolved = urlsplit(urljoin("http://product.invalid" + entry, href)) + resolved_path = unquote(resolved.path).lstrip("/") + try: + path = safe_path(web_root, resolved_path) + value = read_bytes(path, 1024 * 1024) + valid = False + if path.suffix.lower() == ".svg": + validate_icon(value) + valid = True + elif path.suffix.lower() in {".png", ".ico", ".jpg", ".jpeg", ".gif", ".webp", ".avif"}: + # The static-SVG validator cannot prove an arbitrary raster + # favicon renders correctly. Preserve the user's choice but + # do not call a magic-byte/header sniff "validated". + unconfirmed = True + if valid: + usable.append({"href": href, "unified": digest(value) == icon.get("sha256"), + "managed": link.get("data-auto-company-icon") == "v1", "conditional": bool(link.get("media"))}) + except (OSError, ValueError): + continue + if usable: + if any(not row["unified"] for row in usable): + selected = next(row for row in usable if not row["unified"]) + result.update(state="preserved", reason="user_favicon_preserved", href=selected["href"], unified=False) + elif unconfirmed or any(row["conditional"] for row in usable): + result.update(state="unconfirmed", reason="multiple_or_conditional_icon_choices", href=usable[0]["href"]) + else: + result.update(state="linked", reason=None, href=usable[0]["href"], unified=True, + managed=any(row["managed"] for row in usable)) + return result + if unconfirmed: + result.update(state="unconfirmed", reason="existing_icon_reference_unconfirmed") + return result + if parser.owned: + result.update(state="conflict", reason="owned_reference_modified") + return result + if not canonical_valid or icon.get("publicationStatus") not in {"published", "existing_valid"}: + result.update(state="conflict", reason="canonical_icon_unavailable") + return result + result.update(state="missing", reason="icon_reference_missing") + if not write: + return result + # Insert one metadata link at a parsed head boundary, preserving every + # original byte and all existing user-authored references/content. + newline = "\r\n" if "\r\n" in text else "\n" + href = "../" * (len(PurePosixPath(relative).parts) - 1) + "auto-company-icon.svg" + link = f'' + offset = parser.close_offset + updated = (text[:offset] + newline + link + newline + text[offset:]).encode("utf-8") + if read_bytes(target, 2 * 1024 * 1024) != raw: + result.update(state="failed", reason="entry_changed_during_icon_linkage") + return result + atomic_bytes(target, updated) + result.update(state="inserted", reason=None, href=href, unified=True, managed=True, + entrySha256=digest(updated)) + except UnicodeError: + result.update(state="unsupported", reason="entry_encoding_unsupported") + except (OSError, ValueError, TypeError): + result.update(state="failed", reason="icon_reference_unavailable") + return result diff --git a/scripts/core/product_identity.py b/scripts/core/product_identity.py new file mode 100644 index 00000000..08fd482d --- /dev/null +++ b/scripts/core/product_identity.py @@ -0,0 +1,521 @@ +"""Durable product identities and cycle reservations, independent of log retention. + +The runtime store is authoritative. A small source marker only detects path +reuse and supports explicit relocation; it never supplies counters or history. +Readers do not create directories, identities, locks or projection sidecars. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import sys +import tempfile +import time +import uuid + +ID = re.compile(r"[0-9a-f]{32}\Z") +CYCLE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z") +PROJECT = re.compile(r"projects/[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\Z") +OPEN = {"reserved", "dispatching"} +TERMINAL = {"completed", "completed_with_timeout", "failed", "interrupted", "not_started", "startup_unconfirmed"} + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +def safe_path(root, relative): + root = Path(root).resolve() + path = root + for part in Path(relative).parts: + if part in {"..", "."} or ":" in part: + raise ValueError("Invalid product state path") + path /= part + if path.is_symlink() or getattr(path, "is_junction", lambda: False)(): + raise ValueError("Product state and source paths must not be links") + path.resolve().relative_to(root) + return path + + +def project_value(project): + project = str(project or "") + if project and not project.startswith("projects/"): + project = "projects/" + project + if project and not PROJECT.fullmatch(project): + raise ValueError("Invalid product project path") + return project + + +def atomic_write(path, data): + descriptor, temporary = tempfile.mkstemp(prefix=".product-", dir=path.parent) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as output: + json.dump(data, output, ensure_ascii=False, sort_keys=True) + output.write("\n") + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + if os.name != "nt": + directory = os.open(path.parent, os.O_RDONLY) + try: + try: + os.fsync(directory) + except OSError: + # Some mounted filesystems do not implement directory fsync. + pass + finally: + os.close(directory) + finally: + Path(temporary).unlink(missing_ok=True) + + +def empty_state(): + return {"schemaVersion": 1, "identities": {}, "paths": {}, "cycles": {}, + "explorationId": None, "continuationProductId": None} + + +def read_state(root): + path = safe_path(root, ".auto-company/product-state.json") + if not path.exists(): + return empty_state() + try: + return validate_state(json.loads(path.read_text(encoding="utf-8"))) + except (UnicodeError, json.JSONDecodeError) as error: + raise ValueError("Invalid product state; refusing to reset cycle numbering") from error + + +def validate_state(state): + try: + if state.get("schemaVersion") != 1: + raise ValueError("Unsupported product state schema") + for key in ("identities", "paths", "cycles"): + if not isinstance(state.get(key), dict): + raise ValueError("Invalid product state") + for identity, item in state["identities"].items(): + if (not ID.fullmatch(identity) or item["id"] != identity + or item["kind"] not in {"product", "exploration"} + or type(item["lastCycleNumber"]) is not int or item["lastCycleNumber"] < 0 + or project_value(item["project"]) != item["project"]): + raise ValueError("Invalid product identity") + seen = set() + for cycle_id, row in state["cycles"].items(): + owner = state["identities"].get(row["identityId"]) + pair = (row["identityId"], row["productCycleNumber"]) + if (not CYCLE.fullmatch(cycle_id) or row["cycleId"] != cycle_id or not owner + or row["state"] not in OPEN | TERMINAL or pair in seen + or type(row["productCycleNumber"]) is not int + or not 1 <= row["productCycleNumber"] <= owner["lastCycleNumber"]): + raise ValueError("Invalid cycle reservation") + seen.add(pair) + for project, identity in state["paths"].items(): + if (project_value(project) != project or not project + or state["identities"][identity]["project"] != project): + raise ValueError("Invalid source binding") + for key in ("explorationId", "continuationProductId"): + if state.get(key) is not None and state[key] not in state["identities"]: + raise ValueError("Invalid runtime continuation") + return state + except (KeyError, TypeError, AttributeError, UnicodeError, json.JSONDecodeError) as error: + raise ValueError("Invalid product state; refusing to reset cycle numbering") from error + + +def state_hash(state): + return hashlib.sha256(json.dumps(state, sort_keys=True).encode()).hexdigest() + + +def recover_transaction(root): + """Redo a committed transaction intent after a stopped-writer lock recovery.""" + path = safe_path(root, ".auto-company/product-state.transaction.json") + if not path.exists(): + return + intent = json.loads(path.read_text(encoding="utf-8")) + state = validate_state(intent["after"]) + current = read_state(root) + if state_hash(current) not in {intent["beforeHash"], state_hash(state)}: + raise ValueError("Product transaction conflicts with current state; refusing to overwrite") + for marker in intent["markers"]: + project = project_value(marker["project"]) + if not project or not safe_path(root, project).is_dir() or not ID.fullmatch(marker["id"]): + raise ValueError("Pending product source registration cannot be recovered") + current_id = _marker_id(root, project) + previous = marker["previous"] + if current_id not in {marker["id"], previous["id"] if previous else None}: + raise ValueError("Pending source identity changed during recovery") + target = _marker(root, project) + target.parent.mkdir(exist_ok=True) + atomic_write(target, {"schemaVersion": 1, "id": marker["id"]}) + atomic_write(safe_path(root, ".auto-company/product-state.json"), state) + path.unlink() + + +def commit_transaction(root, before, state, markers): + path = safe_path(root, ".auto-company/product-state.transaction.json") + committed = False + try: + # Stage marker changes and the complete reservation together before any + # source mutation. A crash can redo this intent, never reset a counter. + atomic_write(path, {"schemaVersion": 1, "beforeHash": state_hash(before), + "after": state, "markers": markers}) + for marker in markers: + target = _marker(root, marker["project"]) + target.parent.mkdir(exist_ok=True) + atomic_write(target, {"schemaVersion": 1, "id": marker["id"]}) + atomic_write(safe_path(root, ".auto-company/product-state.json"), state) + committed = True + path.unlink() + except Exception: + if not committed: + for marker in reversed(markers): + target = _marker(root, marker["project"]) + if marker["previous"] is None: + target.unlink(missing_ok=True) + else: + atomic_write(target, marker["previous"]) + path.unlink(missing_ok=True) + raise + + +@contextmanager +def transaction(root, timeout=5): + folder = safe_path(root, ".auto-company") + folder.mkdir(exist_ok=True) + lock = safe_path(root, ".auto-company/product-state.lock") + deadline = time.monotonic() + timeout + while True: + try: + lock.mkdir() + break + except FileExistsError: + if time.monotonic() >= deadline: + raise ValueError("Product state is busy; after stopping all writers, use recover-lock --confirm STOPPED") + time.sleep(0.025) + try: + recover_transaction(root) + state = read_state(root) + before = json.loads(json.dumps(state)) + markers = [] + state["_pendingMarkers"] = markers + yield state + del state["_pendingMarkers"] + commit_transaction(root, before, state, markers) + finally: + lock.rmdir() + + +def _marker(root, project): + return safe_path(root, project + "/.auto-company/identity.json") + + +def _marker_id(root, project): + path = _marker(root, project) + if not path.exists(): + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + if value.get("schemaVersion") != 1 or not ID.fullmatch(value.get("id", "")): + raise ValueError("Invalid product source marker") + return value["id"] + except (AttributeError, TypeError, UnicodeError, json.JSONDecodeError) as error: + raise ValueError("Invalid product source marker") from error + + +def _identity(root, state, project, create=False, replace_source=False): + project = project_value(project) + if not project: + identity = state.get("explorationId") + if identity: + return state["identities"][identity] + else: + source = safe_path(root, project) + if not source.is_dir(): + raise ValueError("Registered product source is missing") + identity = state["paths"].get(project) + marker = _marker_id(root, project) + if identity: + item = state["identities"][identity] + if marker == identity: + return item + # A replacement directory cannot inherit an earlier product's ID. + if not create: + return None + if not replace_source: + raise ValueError("Product source identity is missing or changed; explicitly register a replacement") + elif marker: + raise ValueError("Source marker has no matching path registration; relocate explicitly") + if not create: + return None + identity = uuid.uuid4().hex + item = {"id": identity, "kind": "product" if project else "exploration", "project": project, + "createdAt": now(), "lastCycleNumber": 0, "source": "runtime_registration"} + if project: + marker = _marker(root, project) + previous = json.loads(marker.read_text(encoding="utf-8")) if marker.exists() else None + state["_pendingMarkers"].append({"project": project, "id": identity, "previous": previous}) + state["paths"][project] = identity + else: + state["explorationId"] = identity + state["identities"][identity] = item + return item + + +def get_identity(root, project, create=False): + """Return the authoritative identity, or None; reads never register sources.""" + if create: + with transaction(root) as state: + return dict(_identity(root, state, project, True)) + return _identity(root, read_state(root), project) + + +def continuation_project(root): + state = read_state(root) + identity = state.get("continuationProductId") + if not identity: + return "" + item = state["identities"][identity] + current = _identity(root, state, item["project"]) + if not current or current["id"] != identity: + raise ValueError("Runtime continuation source no longer matches its product") + return item["project"] + + +def register_project(root, project, cycle_id=""): + """Explicit creation links one exploration task to its created product.""" + with transaction(root) as state: + if cycle_id: + cycle = state["cycles"].get(cycle_id) + if not cycle or cycle["state"] not in OPEN: + raise ValueError("Creation requires an open runtime cycle") + owner = state["identities"][cycle["identityId"]] + linked = owner.get("linkedProductId") + if linked and state["identities"][linked]["project"] != project_value(project): + raise ValueError("Exploration already linked to a product; do not infer another execution target") + item = _identity(root, state, project, True, replace_source=True) + if cycle_id: + cycle.setdefault("createdProductIds", []) + if item["id"] not in cycle["createdProductIds"]: + cycle["createdProductIds"].append(item["id"]) + owner = state["identities"][cycle["identityId"]] + if owner["kind"] == "exploration": + if owner.get("linkedProductId") not in (None, item["id"]): + raise ValueError("Exploration already linked to a product; do not infer another execution target") + owner["linkedProductId"] = item["id"] + item["explorationId"] = owner["id"] + state["continuationProductId"] = item["id"] + return dict(item) + + +def relocate_identity(root, identity, project): + """Explicit path update; a source marker must already prove the same ID.""" + project = project_value(project) + if not project: + raise ValueError("Relocation requires a product path") + with transaction(root) as state: + item = state["identities"].get(identity) + if not item or item["kind"] != "product" or _marker_id(root, project) != identity: + raise ValueError("Relocation source does not match the registered product") + if state["paths"].get(project) not in (None, identity): + raise ValueError("Relocation target is already registered") + if state["paths"].get(item["project"]) == identity: + del state["paths"][item["project"]] + item["project"] = project + state["paths"][project] = identity + return dict(item) + + +def _projection(state, row): + owner = state["identities"][row["identityId"]] + return {**row, "schemaVersion": 1, "numbering": "persistent", "kind": owner["kind"], + "productId": owner["id"] if owner["kind"] == "product" else None, + "explorationId": owner["id"] if owner["kind"] == "exploration" else owner.get("explorationId"), + "linkedProductId": owner.get("linkedProductId"), + "source": "product_cycle_ledger"} + + +def cycle_projection(root, cycle_id): + state = read_state(root) + row = state["cycles"].get(cycle_id) + return _projection(state, row) if row else None + + +def cycle_projects(root, cycle_id): + state = read_state(root) + row = state["cycles"].get(cycle_id) + if not row: + return [] + ids = [row["identityId"], *row.get("createdProductIds", [])] + return list(dict.fromkeys(state["identities"][identity]["project"] for identity in ids + if state["identities"][identity]["kind"] == "product")) + + +def list_cycle_projections(root, product_id=None, limit=200, before=None): + state = read_state(root) + limit = max(1, min(int(limit), 2000)) + rows = [] + for row in state["cycles"].values(): + owner = state["identities"][row["identityId"]] + if product_id and owner["id"] != product_id and owner.get("linkedProductId") != product_id: + continue + rows.append(row) + rows.sort(key=lambda row: (row["reservedAt"], row["cycleId"]), reverse=True) + total = len(rows) + if before: + position = next((index for index, row in enumerate(rows) if row["cycleId"] == before), None) + if position is None: + raise ValueError("Unknown cycle cursor") + rows = rows[position + 1:] + selected = rows[:limit] + return {"cycles": [_projection(state, row) for row in selected], "total": total, + "nextBefore": selected[-1]["cycleId"] if len(rows) > limit else None} + + +def reserve_cycle(root, project, attempt_id, run_cycle_number, engine="", model=""): + if not CYCLE.fullmatch(attempt_id) or type(run_cycle_number) is not int or run_cycle_number < 1: + raise ValueError("Invalid cycle attempt") + project = project_value(project) + with transaction(root) as state: + previous = next((row for row in state["cycles"].values() if row["attemptId"] == attempt_id), None) + if previous: + if previous["project"] != project or previous["runCycleNumber"] != run_cycle_number: + raise ValueError("Cycle attempt was already reserved with different context") + return _projection(state, previous) + if any(row["state"] in OPEN for row in state["cycles"].values()): + raise ValueError("Recover the unfinished cycle before reserving another attempt") + item = _identity(root, state, project, True) + item["lastCycleNumber"] += 1 + cycle_id = "cycle-" + uuid.uuid4().hex + row = {"cycleId": cycle_id, "attemptId": attempt_id, "identityId": item["id"], + "project": project, "productCycleNumber": item["lastCycleNumber"], + "runCycleNumber": run_cycle_number, "reservedAt": now(), "startedAt": None, + "endedAt": None, "state": "reserved", "engine": engine, "model": model} + state["cycles"][cycle_id] = row + return _projection(state, row) + + +def update_cycle(root, cycle_id, status, reason=""): + if status not in {"dispatching"} | TERMINAL: + raise ValueError("Invalid cycle state") + with transaction(root) as state: + row = state["cycles"].get(cycle_id) + if not row: + raise ValueError("Unknown cycle identity") + if row["state"] in TERMINAL: + if row["state"] != status: + raise ValueError("A closed cycle cannot be relabeled") + return _projection(state, row) + row["state"] = status + if status == "dispatching": + row["dispatchAt"] = now() + else: + if status in {"completed", "completed_with_timeout", "failed"}: + row["startedAt"] = row.get("dispatchAt") + row["endedAt"] = now() + row["reason"] = reason[:1000] + return _projection(state, row) + + +def recover_cycles(root): + """Caller holds the loop ownership lock; never replay an uncertain attempt.""" + if not any(safe_path(root, ".auto-company/" + name).exists() + for name in ("product-state.json", "product-state.transaction.json")): + return [] + recovered = [] + with transaction(root) as state: + completed = {} + usage = safe_path(root, "logs/usage.jsonl") + if usage.is_file(): + with usage.open(encoding="utf-8") as source: + for line in source: + try: + record = json.loads(line) + if record.get("kind") == "cycle_usage" and record.get("status") in TERMINAL: + completed[record["cycle_id"]] = record + except (ValueError, KeyError, TypeError, AttributeError): + continue + for row in state["cycles"].values(): + if row["state"] not in OPEN: + continue + status = "not_started" if row["state"] == "reserved" else "startup_unconfirmed" + # Only recorded output from this exact cycle confirms an invocation. + # An empty log, context, PID or dispatch intent is not evidence. + for suffix in (".log", ".events.jsonl"): + evidence = safe_path(root, "logs/" + row["cycleId"] + suffix) + if evidence.is_file() and evidence.stat().st_size: + status = "interrupted" + finished = completed.get(row["cycleId"]) + if finished and finished["status"] != "interrupted": + status = finished["status"] + row["startedAt"] = finished.get("started_at") + row.update(state=status, endedAt=now(), reason="Recovered unfinished runtime attempt; no automatic replay") + recovered.append(_projection(state, row)) + return recovered + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--project", default="") + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("read") + sub.add_parser("continuation") + create = sub.add_parser("register") + create.add_argument("--cycle", default="") + reserve = sub.add_parser("reserve") + reserve.add_argument("--attempt", required=True) + reserve.add_argument("--run-cycle", type=int, required=True) + reserve.add_argument("--engine", default="") + reserve.add_argument("--model", default="") + reserve.add_argument("--shell", action="store_true") + update = sub.add_parser("update") + update.add_argument("--cycle", required=True) + update.add_argument("--state", required=True) + update.add_argument("--reason", default="") + sub.add_parser("recover") + unlock = sub.add_parser("recover-lock") + unlock.add_argument("--confirm", required=True) + move = sub.add_parser("relocate") + move.add_argument("--id", required=True) + args = parser.parse_args() + try: + if args.command == "read": + result = get_identity(args.root, args.project) + elif args.command == "continuation": + print(continuation_project(args.root)) + return 0 + elif args.command == "register": + result = register_project(args.root, args.project, args.cycle) + elif args.command == "reserve": + result = reserve_cycle(args.root, args.project, args.attempt, args.run_cycle, args.engine, args.model) + if args.shell: + # Fixed validated tokens only; callers parse fields, never eval. + print(result["cycleId"], result["identityId"], result["productCycleNumber"], result["kind"]) + return 0 + elif args.command == "update": + result = update_cycle(args.root, args.cycle, args.state, args.reason) + elif args.command == "recover": + result = recover_cycles(args.root) + elif args.command == "relocate": + result = relocate_identity(args.root, args.id, args.project) + else: + if args.confirm != "STOPPED" or os.environ.get("AUTO_COMPANY_CYCLE") == "1": + raise ValueError("Confirm every loop, Dashboard, team and product-state writer is stopped") + lock = safe_path(args.root, ".auto-company/product-state.lock") + if lock.exists(): + lock.rmdir() + with transaction(args.root): + pass + result = {"recovered": True} + print(json.dumps(result, ensure_ascii=False)) + return 0 + except (OSError, ValueError) as error: + print("Product cycle state: " + str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/core/product_media.py b/scripts/core/product_media.py new file mode 100644 index 00000000..5479ffdd --- /dev/null +++ b/scripts/core/product_media.py @@ -0,0 +1,659 @@ +"""Bounded, program-triggered product media; reads never create or start anything. + +The only executable profile is an explicitly configured local Node entrypoint. +Generated artifacts live outside the product and are served only by digest. +""" +from __future__ import annotations + +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import signal +import stat +import subprocess +import tempfile +import threading +import time +import uuid +import xml.etree.ElementTree as ET +import zlib + + +SCHEMA = 1 +MAX_BYTES = 24 * 1024 * 1024 +MAX_FILES = 2048 +MAX_IMAGE = 12 * 1024 * 1024 +PROFILE = ".auto-company/media.json" +VIEWPORTS = [{"name": "desktop", "width": 1440, "height": 1000}, + {"name": "mobile", "width": 390, "height": 844}] +IGNORED = {".git", ".auto-company", "node_modules", "__pycache__", ".venv", "venv", + "test-results", "playwright-report", "coverage"} +WEB_SUFFIXES = {".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".svg", ".png", + ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".woff", ".woff2", ".ttf", ".txt", ".wasm"} +SOURCE_SUFFIXES = WEB_SUFFIXES | {".ts", ".tsx", ".jsx", ".vue", ".svelte"} + + +class MediaError(ValueError): + def __init__(self, code): + self.code = code + super().__init__(code) + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +def digest(value): + return hashlib.sha256(value).hexdigest() + + +def safe_path(root, relative): + if not isinstance(relative, str) or not relative or "\\" in relative or ":" in relative or "\x00" in relative: + raise MediaError("invalid_path") + parts = PurePosixPath(relative) + if parts.is_absolute() or ".." in parts.parts: + raise MediaError("invalid_path") + root = Path(root) + path = root + if root.is_symlink() or getattr(root, "is_junction", lambda: False)(): + raise MediaError("linked_path") + for part in parts.parts: + path /= part + if path.is_symlink() or getattr(path, "is_junction", lambda: False)(): + raise MediaError("linked_path") + path.resolve().relative_to(root.resolve()) + return path + + +def read_bytes(path, limit): + metadata = path.stat() + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > limit: + raise MediaError("file_limit") + with path.open("rb") as source: + value = source.read(limit + 1) + if len(value) > limit: + raise MediaError("file_limit") + return value + + +def read_json(path): + value = json.loads(read_bytes(path, 128 * 1024)) + if not isinstance(value, dict): + raise MediaError("invalid_record") + return value + + +def atomic_bytes(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(prefix=".media-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(value) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + finally: + Path(name).unlink(missing_ok=True) + + +def atomic_json(path, value): + atomic_bytes(path, (json.dumps(value, ensure_ascii=False, sort_keys=True) + "\n").encode()) + + +def identity(root, project): + from product_identity import get_identity + result = get_identity(root, project, create=False) + if not result or result.get("kind") != "product" or not re.fullmatch(r"[0-9a-f]{32}", result.get("id", "")): + raise MediaError("identity_missing") + return result + + +def media_folder(root, product_id): + if not isinstance(product_id, str) or not re.fullmatch(r"[0-9a-f]{32}", product_id): + raise MediaError("invalid_identity") + return safe_path(root, "logs/product-media/" + product_id) + + +def local_url_path(value): + if not isinstance(value, str) or not re.fullmatch(r"/[A-Za-z0-9_./-]*", value) or ".." in value.split("/"): + raise MediaError("invalid_url_path") + return value + + +def load_profile(project): + path = safe_path(project, PROFILE) + if path.exists(): + profile = read_json(path) + if profile.get("version") != SCHEMA: + raise MediaError("unsupported_profile") + elif safe_path(project, "index.html").is_file(): + profile = {"version": SCHEMA, "type": "static", "webRoot": "."} + else: + return {"version": SCHEMA, "type": "unsupported"} + if profile.get("type") in {"none", "unsupported"}: + return {"version": SCHEMA, "type": profile["type"]} + if profile.get("type") not in {"static", "node"}: + raise MediaError("unsupported_profile") + allowed = {"version", "type", "webRoot", "entry", "readySelector", "steps", "timeoutSeconds", "command", "healthPath", "icon"} + if set(profile) - allowed: + raise MediaError("unknown_profile_field") + directory = profile.get("webRoot", ".") + if not safe_path(project, directory).is_dir(): + raise MediaError("web_root_missing") + result = {"version": SCHEMA, "type": profile["type"], "webRoot": directory, + "entry": local_url_path(profile.get("entry", "/")), + "readySelector": profile.get("readySelector", "body"), + "timeoutSeconds": profile.get("timeoutSeconds", 10), "steps": profile.get("steps", [])} + if (not isinstance(result["readySelector"], str) or not 1 <= len(result["readySelector"]) <= 200 + or type(result["timeoutSeconds"]) is not int or not 1 <= result["timeoutSeconds"] <= 15): + raise MediaError("invalid_readiness") + if not isinstance(result["steps"], list) or len(result["steps"]) > 6: + raise MediaError("invalid_steps") + for step in result["steps"]: + if not isinstance(step, dict) or step.get("action") not in {"click", "fill"} or set(step) - {"action", "selector", "value"}: + raise MediaError("invalid_steps") + if not isinstance(step.get("selector"), str) or not 1 <= len(step["selector"]) <= 200: + raise MediaError("invalid_steps") + if step["action"] == "fill" and (not isinstance(step.get("value"), str) or len(step["value"]) > 500): + raise MediaError("invalid_steps") + if "icon" in profile: + safe_path(project, profile["icon"]) + result["icon"] = profile["icon"] + if result["type"] == "node": + command = profile.get("command") + if (not isinstance(command, list) or not 2 <= len(command) <= 32 or command[0] != "node" + or any(not isinstance(arg, str) or len(arg) > 500 or "\x00" in arg for arg in command)): + raise MediaError("invalid_command") + executable = safe_path(project, command[1]) + if executable.suffix not in {".js", ".mjs", ".cjs"} or not executable.is_file(): + raise MediaError("node_entry_missing") + if sum(arg.count("{port}") for arg in command) != 1: + raise MediaError("port_placeholder_required") + result.update(command=command, healthPath=local_url_path(profile.get("healthPath", "/"))) + return result + + +def content_version(project, profile): + """Hash source bytes + normalized profile; ignore generated/check dependency trees.""" + web_root = safe_path(project, profile.get("webRoot", ".")) + root = project if profile.get("type") == "node" else web_root + hasher = hashlib.sha256(json.dumps({"profile": profile, "viewports": VIEWPORTS, "captureVersion": 1}, sort_keys=True).encode()) + count = total = 0 + for directory, dirs, files in os.walk(root, followlinks=False): + dirs[:] = sorted(name for name in dirs if name not in IGNORED and not name.startswith(".")) + for name in dirs: + safe_path(root, (Path(directory) / name).relative_to(root).as_posix()) + for name in sorted(files): + if name.startswith(".") or Path(name).suffix.lower() not in SOURCE_SUFFIXES: + continue + relative = (Path(directory) / name).relative_to(root).as_posix() + path = safe_path(root, relative) + raw = read_bytes(path, MAX_BYTES) + count += 1 + total += len(raw) + if count > MAX_FILES or total > MAX_BYTES: + raise MediaError("source_limit") + hasher.update(relative.encode() + b"\0" + digest(raw).encode() + b"\n") + return hasher.hexdigest() + + +SVG_TAGS = {"svg", "g", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon"} +SVG_ATTRIBUTES = {"viewBox", "width", "height", "x", "y", "x1", "x2", "y1", "y2", "cx", "cy", "r", "rx", "ry", "d", "points", + "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "fill-rule", "clip-rule", "opacity", "fill-opacity", "stroke-opacity", "transform"} + + +def validate_icon(raw): + """Static shapes only: no links, style, text, entities, animation or foreign XML.""" + if len(raw) > 32 * 1024 or b" 64 or element.tag not in {"svg", "{http://www.w3.org/2000/svg}svg"}: + raise MediaError("unsafe_icon") + for node in nodes: + tag = node.tag.removeprefix("{http://www.w3.org/2000/svg}") + if tag not in SVG_TAGS or (node.text or "").strip() or (node.tail or "").strip(): + raise MediaError("unsafe_icon") + for key, value in node.attrib.items(): + if key not in SVG_ATTRIBUTES or len(value) > 8192 or not re.fullmatch(r"[A-Za-z0-9#.,()+\-\s%]*", value) or "url" in value.lower(): + raise MediaError("unsafe_icon") + if key in {"fill", "stroke"} and not re.fullmatch(r"#[0-9a-fA-F]{3,8}|none|currentColor|[A-Za-z]{1,20}", value): + raise MediaError("unsafe_icon") + viewbox = element.get("viewBox", "").replace(",", " ").split() + try: + bounds = [float(value) for value in viewbox] + if len(bounds) != 4 or not all(-10000 < value < 10000 for value in bounds) or not (0 < bounds[2] <= 2048 and 0 < bounds[3] <= 2048): + raise ValueError() + except ValueError as error: + raise MediaError("invalid_icon_dimensions") from error + return raw + + +def register_icon(root, project, product_id, profile, previous=None): + source = "default" + reason = "icon_missing" + raw = None + for relative in dict.fromkeys([profile.get("icon", "icon.svg"), "favicon.svg", "public/icon.svg", "public/favicon.svg"]): + try: + path = safe_path(project, relative) + if path.exists(): + raw = validate_icon(read_bytes(path, 32 * 1024)) + source, reason = "product", None + break + except (OSError, ValueError): + reason = "icon_rejected" + if raw is None: + color = "#" + digest(product_id.encode())[:6] + raw = f''.encode() + checksum = digest(raw) + name = f"{checksum}.svg" + target = safe_path(media_folder(root, product_id), name) + if not target.exists() or digest(read_bytes(target, 32 * 1024)) != checksum: + atomic_bytes(target, raw) + record = {"name": name, "sha256": checksum, "source": source, "reason": reason, "recordedAt": now(), + "publicationStatus": "not_applicable"} + if profile.get("type") in {"static", "node"}: + relative = (PurePosixPath(profile.get("webRoot", ".")) / "auto-company-icon.svg").as_posix() + try: + published = safe_path(project, relative) + previous = previous or {} + old_hash = digest(read_bytes(published, 32 * 1024)) if published.exists() else None + owned = (previous.get("publicationStatus") == "published" and previous.get("publishedPath") == relative + and previous.get("publishedSha256") == old_hash) + if old_hash is None or owned: + if old_hash != checksum: + atomic_bytes(published, raw) + record.update(publicationStatus="published", publishedPath=relative, publishedSha256=checksum) + elif old_hash == checksum: + record.update(publicationStatus="existing_valid", publishedPath=relative) + else: + record.update(publicationStatus="conflict", publicationReason="existing_icon_preserved", publishedPath=relative) + except (OSError, ValueError): + record.update(publicationStatus="failed", publicationReason="icon_publication_failed") + from product_icon_html import inspect_reference + record["reference"] = inspect_reference(project, profile, record, write=True) + return record + + +@contextmanager +def capture_lock(folder): + """Bind one writer OS, then use its crash-released kernel lock. + + Windows byte locks and Linux flock are not equivalent on shared NTFS. The + atomic writer binding prevents those APIs from becoming concurrent writers. + """ + owner_path = safe_path(folder, "writer.json") + try: + descriptor = os.open(owner_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + try: + owner = read_json(owner_path) + except (OSError, ValueError) as error: + raise MediaError("writer_binding_unavailable") from error + if owner.get("version") != 1 or owner.get("os") != os.name: + raise MediaError("writer_runtime_mismatch") + else: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump({"version": 1, "os": os.name}, stream) + stream.flush() + os.fsync(stream.fileno()) + lock = safe_path(folder, "capture.lock") + with lock.open("a+b") as stream: + if os.name == "nt": + import msvcrt + if os.fstat(stream.fileno()).st_size == 0: + stream.write(b"0") + stream.flush() + stream.seek(0) + try: + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + except OSError as error: + raise MediaError("capture_busy") from error + try: + yield + finally: + stream.seek(0) + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + try: + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as error: + raise MediaError("capture_busy") from error + try: + yield + finally: + fcntl.flock(stream, fcntl.LOCK_UN) + + +def stop_worker(scope): + scope.close() + + +def run_worker(project, profile, version, staging): + node = shutil.which("node") + if not node: + raise MediaError("node_missing") + request = {"project": str(project), "profile": profile, "version": version, "token": uuid.uuid4().hex, + "output": str(staging), "viewports": VIEWPORTS, "webSuffixes": sorted(WEB_SUFFIXES)} + atomic_json(staging / "request.json", request) + command = [node, str(Path(__file__).with_name("product_media_worker.cjs")), str(staging / "request.json")] + from product_media_process import ProcessScope + scope = None + previous_signal = None + interrupted = False + if threading.current_thread() is threading.main_thread(): + previous_signal = signal.getsignal(signal.SIGTERM) + + def interrupt_capture(_signal, _frame): + # Do not raise asynchronously: process-stat parsing catches + # ValueError, and a repeated signal must not interrupt teardown. + nonlocal interrupted + interrupted = True + + signal.signal(signal.SIGTERM, interrupt_capture) + try: + scope = ProcessScope(command) + process = scope.process + deadline = time.monotonic() + 45 + result_path = staging / "result.json" + while not result_path.exists(): + scope.observe() + if interrupted: + raise MediaError("capture_interrupted") + if process.poll() is not None: + raise MediaError("worker_terminated") + if time.monotonic() >= deadline: + raise MediaError("capture_timeout") + time.sleep(0.05) + result = read_json(result_path) + if result.get("state") != "success": + reason = result.get("reason", "capture_failed") + raise MediaError(reason if isinstance(reason, str) and re.fullmatch(r"[a-z_]{1,64}", reason) else "capture_failed") + return result + finally: + try: + if scope is not None: + stop_worker(scope) + finally: + if previous_signal is not None: + signal.signal(signal.SIGTERM, previous_signal) + if interrupted: + raise MediaError("capture_interrupted") + + +def load_record(folder): + try: + value = read_json(safe_path(folder, "media.json")) + if any(key in value and not isinstance(value[key], dict) for key in ("attempt", "icon", "latestSuccess")): + return {} + return value if value.get("version") == SCHEMA else {} + except (OSError, ValueError, RecursionError): + return {} + + +def validate_variant(folder, variant): + name = variant.get("name", "") + if not re.fullmatch(r"[0-9a-f]{64}\.png", name) or variant.get("sha256") != name[:-4]: + raise MediaError("invalid_image") + raw = read_bytes(safe_path(folder, name), MAX_IMAGE) + if digest(raw) != variant["sha256"] or not raw.startswith(b"\x89PNG\r\n\x1a\n") or len(raw) < 24: + raise MediaError("invalid_image") + width, height = int.from_bytes(raw[16:20], "big"), int.from_bytes(raw[20:24], "big") + if width != variant.get("width") or height != variant.get("height") or not 1 <= width <= 2000 or not 1 <= height <= 2000: + raise MediaError("invalid_image") + offset, seen_data, ended = 8, False, False + while offset + 12 <= len(raw): + length = int.from_bytes(raw[offset:offset + 4], "big") + end = offset + 12 + length + if end > len(raw): + raise MediaError("invalid_image") + chunk = raw[offset + 4:offset + 8 + length] + if zlib.crc32(chunk) != int.from_bytes(raw[offset + 8 + length:end], "big"): + raise MediaError("invalid_image") + seen_data = seen_data or chunk[:4] == b"IDAT" + if chunk[:4] == b"IEND": + ended = length == 0 and end == len(raw) + break + offset = end + if not seen_data or not ended: + raise MediaError("invalid_image") + return raw + + +def valid_success(folder, value): + if not isinstance(value, dict) or not re.fullmatch(r"[0-9a-f]{64}", value.get("version", "")): + return None + try: + variants = value["variants"] + if not isinstance(variants, list) or len(variants) != 2 or any(not isinstance(row, dict) for row in variants) or {row["viewport"] for row in variants} != {"desktop", "mobile"}: + return None + for row in variants: + validate_variant(folder, row) + return value + except (OSError, ValueError, TypeError, KeyError): + return None + + +def retain_images(folder, record): + """Keep all distinct deliveries and five ordinary captures; keep all metadata.""" + retained = {record.get("icon", {}).get("name")} + successes = [record.get("latestSuccess")] + ordinary = [] + for path in folder.glob("attempt-*.json"): + try: + row = read_json(safe_path(folder, path.name)) + if row.get("state") == "success" and row.get("variants"): + if row.get("retainedDelivery"): + successes.append(row) + else: + ordinary.append(row) + except (OSError, ValueError): + continue + successes.extend(sorted(ordinary, key=lambda row: row.get("endedAt", ""), reverse=True)[:5]) + for success in successes: + if isinstance(success, dict): + retained.update(row.get("name") for row in success.get("variants", []) if isinstance(row, dict)) + for path in folder.iterdir(): + if re.fullmatch(r"[0-9a-f]{64}\.(?:png|svg)", path.name) and path.name not in retained: + safe_path(folder, path.name).unlink(missing_ok=True) + + +def capture_product(root, project, cycle_id=None, trigger="cycle", readonly=False, retry=False): + """Normal cycle/delivery hook. Does not create product identity or affect checks.""" + if readonly: + raise MediaError("readonly") + root = Path(root) + product = identity(root, project) + source = safe_path(root, project) + folder = media_folder(root, product["id"]) + folder.mkdir(parents=True, exist_ok=True) + with capture_lock(folder): + for candidate in folder.glob(".capture-*"): + candidate = safe_path(folder, candidate.name) + # A crashed owner's worker has its own 55-second deadline. Do not + # remove a still-live worker's staging files during recovery. + if candidate.is_dir() and time.time() - candidate.stat().st_mtime > 65: + shutil.rmtree(candidate) + record = load_record(folder) + record.update(version=SCHEMA, productId=product["id"], project=project) + old_attempt = record.get("attempt") + if old_attempt and old_attempt.get("state") == "capturing": + try: + age = (datetime.now(timezone.utc) - datetime.fromisoformat(old_attempt["startedAt"])).total_seconds() + if age < 60: + # The old owner may have crashed before cleanup; its worker + # watchdog still owns this short remaining preview scope. + raise MediaError("capture_busy") + except (ValueError, TypeError, KeyError) as error: + if isinstance(error, MediaError): + raise + old_attempt.update(state="interrupted", reason="previous_capture_interrupted", endedAt=now()) + if re.fullmatch(r"[0-9a-f]{32}", str(old_attempt.get("id", ""))): + atomic_json(safe_path(folder, f"attempt-{old_attempt['id']}.json"), old_attempt) + attempt = {"id": uuid.uuid4().hex, "cycleId": cycle_id, "trigger": trigger, "startedAt": now(), "state": "capturing"} + staging = None + try: + profile = load_profile(source) + record["icon"] = register_icon(root, source, product["id"], profile, record.get("icon")) + if profile["type"] in {"none", "unsupported"}: + attempt.update(state="not_applicable" if profile["type"] == "none" else "unsupported", reason="profile_not_supported") + return record + version = content_version(source, profile) + attempt["version"] = version + previous = record.get("attempt", {}) + if not retry and previous.get("version") == version and previous.get("state") != "capturing": + # Success is reusable only while every registered image is intact. + if previous.get("state") != "success" or valid_success(folder, record.get("latestSuccess")): + attempt = previous + if trigger == "delivery" and previous.get("state") == "success": + attempt["retainedDelivery"] = True + return record + record["attempt"] = attempt + atomic_json(safe_path(folder, "media.json"), record) + staging = Path(tempfile.mkdtemp(prefix=".capture-", dir=folder)) + result = run_worker(source, profile, version, staging) + if content_version(source, profile) != version: + raise MediaError("version_changed") + variants = [] + for viewport in VIEWPORTS: + raw = read_bytes(safe_path(staging, viewport["name"] + ".png"), MAX_IMAGE) + checksum = digest(raw) + variant = {"name": checksum + ".png", "sha256": checksum, "viewport": viewport["name"], + "width": viewport["width"], "height": viewport["height"]} + target = safe_path(folder, variant["name"]) + atomic_bytes(target, raw) + validate_variant(folder, variant) + variants.append(variant) + attempt.update(state="success", reason=None) + record["latestSuccess"] = {"version": version, "cycleId": cycle_id, "capturedAt": now(), + "pagePath": profile["entry"], "variants": variants, "attemptId": attempt["id"]} + attempt["variants"] = variants + if trigger == "delivery": + attempt["retainedDelivery"] = True + except KeyboardInterrupt: + attempt.update(state="interrupted", reason="capture_interrupted") + raise + except (OSError, ValueError, TypeError, KeyError, subprocess.SubprocessError) as error: + reason = error.code if isinstance(error, MediaError) else "capture_failed" + attempt.update(state="interrupted" if reason == "capture_interrupted" else "failed", reason=reason) + if not record.get("icon"): + record["icon"] = register_icon(root, source, product["id"], {}) + finally: + attempt.setdefault("endedAt", now()) + record["attempt"] = attempt + atomic_json(safe_path(folder, f"attempt-{attempt['id']}.json"), attempt) + atomic_json(safe_path(folder, "media.json"), record) + if staging: + shutil.rmtree(staging) + retain_images(folder, record) + return record + + +def resource_href(product_id, name): + return f"/api/product-media/{product_id}/{name}" + + +def media_projection(root, project, readonly=False): + """Pure read projection, also suitable for archives. Never invokes a browser.""" + result = {"version": SCHEMA, "productId": None, "screenshot": {"state": "missing", "reason": "identity_missing", "latestSuccess": None}, "icon": None} + try: + product = identity(root, project) + product_id = product["id"] + folder = media_folder(root, product_id) + record = load_record(folder) + if record.get("productId") not in {None, product_id}: + raise MediaError("identity_mismatch") + result["productId"] = product_id + screenshot = result["screenshot"] + attempt = record.get("attempt", {}) + screenshot.update(state=attempt.get("state", "missing"), reason=attempt.get("reason"), attemptedAt=attempt.get("startedAt")) + if screenshot["state"] == "capturing": + try: + if (datetime.now(timezone.utc) - datetime.fromisoformat(attempt["startedAt"])).total_seconds() > 60: + screenshot.update(state="interrupted", reason="previous_capture_interrupted") + except (ValueError, TypeError, KeyError): + screenshot.update(state="interrupted", reason="invalid_capture_time") + latest = valid_success(folder, record.get("latestSuccess")) + if latest: + screenshot["latestSuccess"] = {**latest, "variants": [{**row, "href": resource_href(product_id, row["name"])} for row in latest["variants"]]} + elif record.get("latestSuccess"): + screenshot.update(state="failed", reason="artifact_missing_or_modified") + try: + source = safe_path(root, project) + profile = load_profile(source) + if profile["type"] not in {"none", "unsupported"}: + current = content_version(source, profile) + screenshot["currentVersion"] = current + screenshot["stale"] = bool(latest and latest["version"] != current) + if latest and latest["version"] != current and screenshot["state"] == "success": + screenshot["state"] = "stale" + elif not attempt: + screenshot.update(state="not_applicable" if profile["type"] == "none" else "unsupported", reason="profile_not_supported") + elif latest: + screenshot["stale"] = True + except (OSError, ValueError, TypeError): + screenshot["stale"] = bool(latest) + screenshot["reason"] = "source_unavailable" + icon = record.get("icon") + if isinstance(icon, dict) and re.fullmatch(r"[0-9a-f]{64}\.svg", icon.get("name", "")): + raw = read_bytes(safe_path(folder, icon["name"]), 32 * 1024) + if digest(validate_icon(raw)) == icon.get("sha256") == icon["name"][:-4]: + result["icon"] = {**icon, "href": resource_href(product_id, icon["name"])} + from product_icon_html import inspect_reference + try: + reference_project = safe_path(root, project) + result["icon"]["reference"] = inspect_reference(reference_project, load_profile(reference_project), icon) + except (OSError, ValueError, TypeError): + result["icon"]["reference"] = {"state": "unconfirmed", "reason": "source_unavailable", "unified": None, "managed": False} + if icon.get("publicationStatus") in {"published", "existing_valid"}: + try: + published = safe_path(safe_path(root, project), icon["publishedPath"]) + if digest(read_bytes(published, 32 * 1024)) != icon["sha256"]: + result["icon"].update(publicationStatus="stale", publicationReason="published_icon_modified") + except (OSError, ValueError, KeyError): + result["icon"].update(publicationStatus="unavailable", publicationReason="published_icon_missing") + except (OSError, ValueError, TypeError, KeyError, RecursionError): + pass + return result + + +def read_resource(root, product_id, name): + """Read only a currently registered, content-verified image or safe SVG.""" + try: + folder = media_folder(root, product_id) + record = load_record(folder) + if record.get("productId") != product_id: + return None + if not isinstance(name, str) or not re.fullmatch(r"[0-9a-f]{64}\.(?:png|svg)", name): + return None + icon = record.get("icon", {}) + if name.endswith(".svg") and icon.get("name") == name: + raw = validate_icon(read_bytes(safe_path(folder, name), 32 * 1024)) + return (raw, "image/svg+xml") if digest(raw) == icon.get("sha256") == name[:-4] else None + success = valid_success(folder, record.get("latestSuccess")) + if success: + for variant in success["variants"]: + if variant["name"] == name: + return validate_variant(folder, variant), "image/png" + except (OSError, ValueError, TypeError, KeyError, RecursionError): + pass + return None + + +def capture_cycle(root, cycle): + from product_identity import cycle_projects + for project in cycle_projects(root, cycle): + try: + capture_product(root, project, cycle, "cycle") + except (OSError, ValueError, TypeError): + # Media diagnostics never change AI/check/governance outcomes. + continue diff --git a/scripts/core/product_media_process.py b/scripts/core/product_media_process.py new file mode 100644 index 00000000..958843e2 --- /dev/null +++ b/scripts/core/product_media_process.py @@ -0,0 +1,190 @@ +"""Owned renderer process scopes; exited leaders do not orphan their previews.""" +from __future__ import annotations + +import os +from pathlib import Path +import signal +import subprocess +import time + + +def proc_identity(pid): + if not Path("/proc").is_dir(): + return next((row for row in posix_rows(pid) if row["pid"] == pid), None) if os.name != "nt" else None + try: + fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split() + return {"pid": pid, "ppid": int(fields[1]), "group": int(fields[2]), "session": int(fields[3]), "start": int(fields[19]), "state": fields[0]} + except (OSError, ValueError, IndexError): + return None + + +def posix_rows(pid=None): + if Path("/proc").is_dir(): + return [value for entry in Path("/proc").iterdir() if entry.name.isdigit() + if (value := proc_identity(int(entry.name)))] + command = ["ps", "-p", str(pid), "-o", "pid=,ppid=,pgid=,stat=,lstart="] if pid else ["ps", "-axo", "pid=,ppid=,pgid=,stat=,lstart="] + try: + output = subprocess.run(command, capture_output=True, text=True, timeout=1, env={**os.environ, "LC_ALL": "C"}).stdout + except (OSError, subprocess.SubprocessError): + return [] + rows = [] + for line in output.splitlines(): + try: + fields = line.split() + rows.append({"pid": int(fields[0]), "ppid": int(fields[1]), "group": int(fields[2]), "session": int(fields[2]), + "state": fields[3][0], "start": time.mktime(time.strptime(" ".join(fields[4:9]), "%a %b %d %H:%M:%S %Y"))}) + except (ValueError, IndexError): + continue + return rows + + +class WindowsJob: + def __init__(self): + import ctypes + from ctypes import wintypes + + class Basic(ctypes.Structure): + _fields_ = [("process_time", ctypes.c_longlong), ("job_time", ctypes.c_longlong), + ("flags", wintypes.DWORD), ("minimum", ctypes.c_size_t), ("maximum", ctypes.c_size_t), + ("active", wintypes.DWORD), ("affinity", ctypes.c_size_t), + ("priority", wintypes.DWORD), ("scheduling", wintypes.DWORD)] + + class IO(ctypes.Structure): + _fields_ = [(name, ctypes.c_ulonglong) for name in ("read_ops", "write_ops", "other_ops", "read_bytes", "write_bytes", "other_bytes")] + + class Extended(ctypes.Structure): + _fields_ = [("basic", Basic), ("io", IO), ("process_memory", ctypes.c_size_t), + ("job_memory", ctypes.c_size_t), ("peak_process", ctypes.c_size_t), ("peak_job", ctypes.c_size_t)] + + self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) + self.kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] + self.kernel.CreateJobObjectW.restype = wintypes.HANDLE + self.kernel.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD] + self.kernel.SetInformationJobObject.restype = wintypes.BOOL + self.kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] + self.kernel.AssignProcessToJobObject.restype = wintypes.BOOL + self.kernel.CloseHandle.argtypes = [wintypes.HANDLE] + self.handle = self.kernel.CreateJobObjectW(None, None) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + information = Extended() + information.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if not self.kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(information), ctypes.sizeof(information)): + self.close() + raise ctypes.WinError(ctypes.get_last_error()) + + def assign_and_resume(self, process): + import ctypes + from ctypes import wintypes + if not self.kernel.AssignProcessToJobObject(self.handle, wintypes.HANDLE(int(process._handle))): + raise ctypes.WinError(ctypes.get_last_error()) + # Popen closes the initial thread handle. Resume the still-suspended + # process by its owned process handle after assigning the kill-on-close + # job, so even its first child belongs to the same kernel scope. + resume = ctypes.WinDLL("ntdll").NtResumeProcess + resume.argtypes = [wintypes.HANDLE] + resume.restype = ctypes.c_long + if resume(wintypes.HANDLE(int(process._handle))) != 0: + raise OSError("Could not resume the owned media process") + + def close(self): + if self.handle: + self.kernel.CloseHandle(self.handle) + self.handle = None + + +class ProcessScope: + def __init__(self, command): + self.job = WindowsJob() if os.name == "nt" else None + self.members = {} + options = {"creationflags": subprocess.CREATE_NO_WINDOW | 0x4} if self.job else {"start_new_session": True} + self.process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **options) + try: + if self.job: + self.job.assign_and_resume(self.process) + self.leader = proc_identity(self.process.pid) if os.name != "nt" else None + self.observe() + except BaseException: + if self.job: + self.job.close() + if self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=3) + raise + + def observe(self): + if not self.leader: + return + # Session membership survives its leader. Birth identities and pidfds + # are retained before signaling, including a child's own process group. + current = proc_identity(self.process.pid) + if current and current["start"] != self.leader["start"]: + return + rows = posix_rows() + by_pid = {row["pid"]: row for row in rows} + owned = {self.process.pid, *(pid for pid, (birth, _) in self.members.items() + if by_pid.get(pid, {}).get("start") == birth)} + changed = True + while changed: + changed = False + for row in rows: + if row["start"] < self.leader["start"]: + continue + if row["session"] != self.process.pid and row["ppid"] not in owned: + continue + if row["pid"] not in owned: + owned.add(row["pid"]) + changed = True + previous = self.members.get(row["pid"]) + if not previous or previous[0] != row["start"]: + if previous and previous[1] is not None: + os.close(previous[1]) + self.members.pop(row["pid"], None) + try: + descriptor = os.pidfd_open(row["pid"]) if hasattr(os, "pidfd_open") else None + except OSError: + continue + confirmed = proc_identity(row["pid"]) + if not confirmed or confirmed["start"] != row["start"]: + if descriptor is not None: + os.close(descriptor) + continue + self.members[row["pid"]] = (row["start"], descriptor) + + def signal_members(self, value): + for pid, (birth, descriptor) in self.members.items(): + try: + if descriptor is not None and hasattr(signal, "pidfd_send_signal"): + signal.pidfd_send_signal(descriptor, value) + elif (current := proc_identity(pid)) and current["start"] == birth: + os.kill(pid, value) + except (OSError, ProcessLookupError): + pass + + def close(self): + try: + if self.job: + self.job.close() + elif self.leader: + self.observe() + self.signal_members(signal.SIGTERM) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + if all(not (row := proc_identity(pid)) or row["start"] != birth or row["state"] == "Z" + for pid, (birth, _) in self.members.items()): + break + time.sleep(0.05) + self.observe() + self.signal_members(signal.SIGKILL) + elif self.process.poll() is None: + os.killpg(self.process.pid, signal.SIGTERM) + try: + self.process.wait(timeout=2) + except subprocess.TimeoutExpired: + os.killpg(self.process.pid, signal.SIGKILL) + self.process.wait(timeout=3) + finally: + for _, descriptor in self.members.values(): + if descriptor is not None: + os.close(descriptor) + self.members.clear() diff --git a/scripts/core/product_media_worker.cjs b/scripts/core/product_media_worker.cjs new file mode 100644 index 00000000..23718aeb --- /dev/null +++ b/scripts/core/product_media_worker.cjs @@ -0,0 +1,197 @@ +/* Isolated short-lived renderer. Python owns the process tree and final commit. */ +"use strict"; +const fs = require("node:fs"); +const path = require("node:path"); +const http = require("node:http"); +const net = require("node:net"); +const { spawn } = require("node:child_process"); +const { createRequire } = require("node:module"); + +const request = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +let browser, server, child; +let stopping = false; +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const fail = code => Object.assign(new Error(code), { code }); +const identityHeaders = { + "X-Auto-Company-Media": request.token, + "X-Auto-Company-Version": request.version, + "Cache-Control": "no-store", +}; +// Also bound lifetime if the Python owner crashes without running its finally. +// This worker is launched as its own POSIX session / Windows process tree. +const watchdog = setTimeout(async () => { + await Promise.race([cleanup().catch(() => {}), sleep(2000)]); + if (process.platform === "win32") { + spawn("taskkill", ["/PID", String(process.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + } else { + process.kill(-process.pid, "SIGKILL"); + } +}, 55000); +watchdog.unref(); + +function playwright() { + for (const location of ["../media/package.json", "../../tests/browser/package.json"]) { + try { return createRequire(path.resolve(__dirname, location))("playwright"); } + catch (error) { if (error.code !== "MODULE_NOT_FOUND") throw error; } + } + throw fail("playwright_missing"); +} + +function safeFile(root, relative) { + const normalized = relative.replaceAll("\\", "/"); + if (normalized.split("/").some(part => part === ".." || part.startsWith(".")) || normalized.includes(":")) throw fail("unsafe_path"); + let file = root; + for (const part of normalized.split("/").filter(Boolean)) { + file = path.join(file, part); + if (fs.lstatSync(file).isSymbolicLink()) throw fail("unsafe_path"); + } + const actual = fs.realpathSync(file); + const relativeActual = path.relative(fs.realpathSync(root), actual); + if (relativeActual.startsWith("..") || path.isAbsolute(relativeActual)) throw fail("unsafe_path"); + return file; +} + +async function staticPreview() { + const directory = path.resolve(request.project, request.profile.webRoot); + const types = { ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8", ".js": "text/javascript", ".mjs": "text/javascript", + ".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", ".webp": "image/webp", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", ".wasm": "application/wasm" }; + server = http.createServer((incoming, response) => { + try { + if (incoming.method !== "GET" && incoming.method !== "HEAD") throw fail("method_not_allowed"); + const url = new URL(incoming.url, "http://127.0.0.1"); + let relative = decodeURIComponent(url.pathname).replace(/^\/+/, ""); + if (relative.endsWith("/") || !relative) relative += "index.html"; + const file = safeFile(directory, relative); + const suffix = path.extname(file).toLowerCase(); + if (!request.webSuffixes.includes(suffix)) throw fail("unsupported_file"); + const info = fs.statSync(file); + if (!info.isFile() || info.size > 24 * 1024 * 1024) throw fail("unsupported_file"); + response.writeHead(200, { ...identityHeaders, "Content-Type": types[suffix] || "application/octet-stream" }); + if (incoming.method === "HEAD") response.end(); + else fs.createReadStream(file).on("error", () => response.destroy()).pipe(response); + } catch (_) { + response.writeHead(404, identityHeaders); + response.end(); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + return `http://127.0.0.1:${server.address().port}`; +} + +function verifyHeaders(headers) { + return headers["x-auto-company-media"] === request.token && headers["x-auto-company-version"] === request.version; +} + +function health(url) { + return new Promise(resolve => { + const operation = http.get(url, { timeout: 500 }, response => { + response.resume(); + resolve(response.statusCode >= 200 && response.statusCode < 300 && verifyHeaders(response.headers)); + }); + operation.on("error", () => resolve(false)); + operation.on("timeout", () => operation.destroy()); + }); +} + +async function nodePreview() { + const reservation = net.createServer(); + await new Promise((resolve, reject) => { + reservation.once("error", reject); + reservation.listen(0, "127.0.0.1", resolve); + }); + const port = reservation.address().port; + await new Promise(resolve => reservation.close(resolve)); + const args = request.profile.command.slice(1).map(value => value.replace("{port}", String(port))); + child = spawn(process.execPath, args, { + cwd: request.project, shell: false, windowsHide: true, stdio: "ignore", detached: false, + env: { ...process.env, AUTO_COMPANY_MEDIA_TOKEN: request.token, AUTO_COMPANY_MEDIA_VERSION: request.version, + AUTO_COMPANY_MEDIA_PORT: String(port), HOST: "127.0.0.1" }, + }); + let launchError; + child.on("error", error => { launchError = error; }); + const origin = `http://127.0.0.1:${port}`; + const deadline = Date.now() + request.profile.timeoutSeconds * 1000; + while (Date.now() < deadline) { + if (launchError || child.exitCode !== null || child.signalCode !== null) throw fail("preview_launch_failed"); + if (await health(origin + request.profile.healthPath)) return origin; + await sleep(75); + } + throw fail("preview_identity_or_readiness_failed"); +} + +async function cleanup() { + if (stopping) return; + stopping = true; + if (browser) await browser.close().catch(() => {}); + if (server) await new Promise(resolve => { server.closeAllConnections?.(); server.close(resolve); }); + // Descendants remain in this worker's OS process tree/session. The Python + // owner terminates that whole scope while this leader is still alive. +} + +async function main() { + let result; + try { + const { chromium } = playwright(); + try { browser = await chromium.launch({ headless: true, timeout: 10000 }); } + catch (error) { + const detail = String(error); + throw fail(/Executable doesn't exist|browserType\.launch.*executable/i.test(detail) ? "browser_missing" + : /error while loading shared libraries|Host system is missing dependencies/i.test(detail) ? "browser_dependencies_missing" : "browser_launch_failed"); + } + const origin = request.profile.type === "static" ? await staticPreview() : await nodePreview(); + for (const viewport of request.viewports) { + const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, isMobile: viewport.name === "mobile", hasTouch: viewport.name === "mobile", + reducedMotion: "reduce", serviceWorkers: "block" }); + try { + await context.route("**/*", route => { + const target = new URL(route.request().url()); + return target.origin === origin || ["data:", "blob:"].includes(target.protocol) ? route.continue() : route.abort(); + }); + await context.routeWebSocket("**/*", socket => { + const target = new URL(socket.url()); + if (["ws:", "wss:"].includes(target.protocol) && target.host === new URL(origin).host) socket.connectToServer(); + else socket.close(); + }); + const page = await context.newPage(); + page.setDefaultTimeout(request.profile.timeoutSeconds * 1000); + const response = await page.goto(origin + request.profile.entry, { waitUntil: "load", timeout: request.profile.timeoutSeconds * 1000 }); + if (!response || response.status() >= 400 || !verifyHeaders(await response.allHeaders())) throw fail("page_identity_failed"); + await page.locator(request.profile.readySelector).first().waitFor({ state: "visible" }); + for (const step of request.profile.steps) { + const locator = page.locator(step.selector).first(); + if (step.action === "click") await locator.click(); + else await locator.fill(step.value); + } + await page.evaluate(async () => { + await document.fonts.ready; + await Promise.all(Array.from(document.images, image => image.complete ? Promise.resolve() : new Promise(resolve => { + image.addEventListener("load", resolve, { once: true }); image.addEventListener("error", resolve, { once: true }); + }))); + }); + // Recheck the owned endpoint immediately before image capture. A port + // taken by another process cannot become a valid success record. + if (!await health(origin + (request.profile.healthPath || request.profile.entry))) throw fail("preview_identity_lost"); + await page.screenshot({ path: path.join(request.output, `${viewport.name}.png`), fullPage: false, animations: "disabled", timeout: 10000 }); + } finally { await context.close(); } + } + result = { state: "success" }; + } catch (error) { + result = { state: "failed", reason: typeof error.code === "string" && /^[a-z_]+$/.test(error.code) ? error.code : "capture_failed" }; + } finally { + await cleanup(); + } + fs.writeFileSync(path.join(request.output, "result.tmp"), JSON.stringify(result)); + fs.renameSync(path.join(request.output, "result.tmp"), path.join(request.output, "result.json")); + // Hold the leader alive so the owner never signals a recycled PID and can + // reap the entire short-lived preview scope after success or failure. + setInterval(() => {}, 1000); +} + +process.on("SIGTERM", () => { cleanup().catch(() => {}); }); +process.on("SIGINT", () => { cleanup().catch(() => {}); }); +main().catch(() => process.exit(1)); diff --git a/scripts/core/project.sh b/scripts/core/project.sh index 417ffeee..b86b10eb 100755 --- a/scripts/core/project.sh +++ b/scripts/core/project.sh @@ -231,10 +231,12 @@ project_new() { die "project is already registered: $name" fi - local created_at registry_tmp created=0 + local created_at registry_tmp registry_before created=0 created_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" registry_tmp="$(mktemp "$PROJECTS_DIR/.registry.XXXXXX")" - trap 'if [ "$created" -eq 1 ] && [ -d "$target" ]; then rm -rf -- "$target"; fi; rm -f "$registry_tmp"' EXIT + registry_before="$(mktemp "$PROJECTS_DIR/.registry-before.XXXXXX")" + cp "$REGISTRY_FILE" "$registry_before" + trap 'if [ "$created" -eq 1 ] && [ -d "$target" ]; then rm -rf -- "$target"; cp "$registry_before" "$REGISTRY_FILE"; fi; rm -f "$registry_tmp" "$registry_before"' EXIT cp "$REGISTRY_FILE" "$registry_tmp" printf '%s\tprojects/%s\tlocal\t%s\n' "$name" "$name" "$created_at" >> "$registry_tmp" @@ -254,7 +256,21 @@ project_new() { echo "Project metadata unavailable; product creation continues." >&2 mv "$registry_tmp" "$REGISTRY_FILE" + # A normal creation event registers stable identity and, during exploration, + # its explicit continuation. It never edits human-owned ACTIVE_PROJECT. + if ! python3 "$SCRIPT_DIR/product_identity.py" --root "$FRAMEWORK_DIR" --project "projects/$name" register \ + --cycle "${AUTO_COMPANY_CYCLE_ID:-}" >/dev/null; then + # EXIT traps may run after function locals disappear under Bash errexit. + # Roll back this failed registration while its exact paths remain live. + rm -rf -- "$target" + cp "$registry_before" "$REGISTRY_FILE" + rm -f "$registry_tmp" "$registry_before" + created=0 + trap - EXIT + return 1 + fi created=0 + rm -f "$registry_before" trap - EXIT echo "Created independent local Git repository: $target" diff --git a/scripts/core/runtime_artifacts.py b/scripts/core/runtime_artifacts.py index 546cb979..db15f3a5 100644 --- a/scripts/core/runtime_artifacts.py +++ b/scripts/core/runtime_artifacts.py @@ -237,13 +237,11 @@ def preview_request(record, stop=False): return False -def finalize(root, cycle): +def finalize(root, cycle, capture=True): """Close only exact-cycle running records after the existing supervisor ends.""" if not isinstance(cycle, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}", cycle): raise ValueError("Valid cycle identity required") folder = safe_path(root, "logs/artifacts") - if not folder.is_dir(): - return for record in artifact_records(root): try: if record.get("version") != 1 or record.get("source") != "runner" or record.get("cycleId") != cycle or record.get("state") != "running": @@ -260,6 +258,14 @@ def finalize(root, cycle): observe(root, record) except (OSError, ValueError, TypeError, KeyError): continue + # The provider supervisor has already ended. Capture owns an independent, + # bounded preview and therefore does not depend on a model opening one. + if capture: + try: + from product_media import capture_cycle + capture_cycle(root, cycle) + except (OSError, ValueError, TypeError): + pass def write_context(root, cycle, project): @@ -307,10 +313,13 @@ def main(): sub.add_parser("prompt") finish = sub.add_parser("finalize") finish.add_argument("--cycle", required=True) + finish.add_argument("--cleanup-only", action="store_true", help="Close records without starting new media work") context = sub.add_parser("context") context.add_argument("--cycle", required=True) document = sub.add_parser("document") document.add_argument("path") + media = sub.add_parser("media", help="Capture the selected product without running a model") + media.add_argument("--retry", action="store_true", help="Create a new attempt even when this version was already attempted") check = sub.add_parser("check") check.add_argument("--report") check.add_argument("--adapter", choices=ADAPTERS) @@ -327,7 +336,7 @@ def main(): print(PROMPT) return 0 if args.action == "finalize": - finalize(root, args.cycle) + finalize(root, args.cycle, capture=not args.cleanup_only) return 0 if args.action == "context": write_context(root, args.cycle, args.project or "") @@ -342,7 +351,20 @@ def main(): path = safe_path(project, args.path) record.update(path=path.relative_to(root).as_posix(), sha256=fingerprint(path), modifiedAt=datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat()) - return 0 if observe(root, record) else 1 + observed = observe(root, record) + if observed: + try: + from product_media import capture_product + capture_product(root, args.project, record["cycleId"], "delivery") + except (OSError, ValueError, TypeError): + pass + return 0 if observed else 1 + if args.action == "media": + from product_media import capture_product, media_projection + capture_product(root, args.project, record["cycleId"], "manual", retry=args.retry) + projection = media_projection(root, args.project) + print(json.dumps(projection, ensure_ascii=False)) + return 0 if projection["screenshot"]["state"] == "success" else 1 if args.action == "check": return run_check(root, project, record, args) if args.action == "preview" and args.background and record["cycleId"]: diff --git a/scripts/media/README.md b/scripts/media/README.md new file mode 100644 index 00000000..330c33a5 --- /dev/null +++ b/scripts/media/README.md @@ -0,0 +1,203 @@ +# Product media runtime + +Normal delivery registration and normal cycle finalization automatically capture +supported products. A model does not need to invoke a preview or screenshot tool. +Media failures are diagnostics and do not change checks or AI cycle results. + +Node 20+ and Playwright Chromium are optional screenshot dependencies. This uses +the same pinned Playwright version as the Dashboard browser checks, without +requiring a Python browser package or an image-generation model. Install from the +framework root in the **same OS/runtime** that runs the product: + +```sh +npm ci --prefix scripts/media +node scripts/media/node_modules/playwright/cli.js install chromium +``` + +Existing `tests/browser/node_modules/playwright` is also reused when available. +The program never downloads dependencies while closing a cycle. Missing Node, +Playwright or Chromium produces a specific diagnostic, retaining the last valid +image. Linux may require Playwright's documented browser system dependencies +(`node scripts/media/node_modules/playwright/cli.js install-deps chromium`). + +## Normal product contract + +A root `index.html` automatically uses the bounded static profile. Other static +web roots may declare `.auto-company/media.json`: + +```json +{ + "version": 1, + "type": "static", + "webRoot": "public", + "entry": "/", + "readySelector": "main", + "icon": "public/icon.svg" +} +``` + +Optional `steps` is a maximum of six `{ "action": "click", "selector": "..." }` +or `{ "action": "fill", "selector": "...", "value": "..." }` records. These +perform ordinary UI interactions on real product content. There is no JavaScript +evaluation step or DOM injection. Use explicit built-in examples where helpful; +do not fabricate completion, check results or business data for a screenshot. +`timeoutSeconds` is an integer from 1 to 15 (default 10). Desktop 1440 × 1000 and +mobile 390 × 844 captures are fixed. Browser requests are limited to the owned +loopback origin; externally hosted fonts/assets and authenticated external +services are outside this contract. Screenshots prove appearance, not correctness. + +For a CLI/nonvisual product use `{ "version": 1, "type": "none" }`. Missing +static entry or unsupported frameworks remain explicitly unsupported. + +An explicit Node development-server profile is supported: + +```json +{ + "version": 1, + "type": "node", + "webRoot": ".", + "command": ["node", "preview.cjs", "{port}"], + "healthPath": "/", + "entry": "/", + "readySelector": "main" +} +``` + +The argv is executed directly (no shell), from the product directory. The first +entry must be `node`, followed by an existing project-relative `.js`, `.cjs` or +`.mjs` file. Exactly one `{port}` placeholder is required. The server must bind to +`127.0.0.1` at that port, fail if it cannot acquire it, and return these headers +on both its health and entry responses: + +```js +{ + "X-Auto-Company-Media": process.env.AUTO_COMPANY_MEDIA_TOKEN, + "X-Auto-Company-Version": process.env.AUTO_COMPANY_MEDIA_VERSION +} +``` + +These random per-capture identity and content-version headers prevent a reused +port from producing a false success. A Vite project can explicitly configure +`server.headers` from these variables and pass its local Vite Node entrypoint +with `--host 127.0.0.1 --port {port} --strictPort`. The program never guesses npm +scripts, installs dependencies, reuses arbitrary URLs or kills a process by port. +The worker owns a short preview scope and recovers it after success, errors or +timeouts, including an exited worker leader. Windows uses a kernel Job Object; +POSIX tracks process birth identities (Linux also uses pidfds). Development +servers must keep their children within the managed process scope rather than +daemonizing or handing execution to an external service. The overall capture +deadline is 45 seconds plus bounded cleanup; +an independent worker watchdog also limits lifetime after owner failure. +Stopping the loop cancels an in-progress cycle-finalization capture. Closed model +and check results remain recorded, and stop cleanup never starts a new capture. + +## Product icon + +Use `icon.svg`, or set the profile's product-relative `icon` path. Existing +`favicon.svg`, `public/icon.svg` and `public/favicon.svg` are also considered. +The normal frontend workflow should author the input `icon.svg` and reference the +program-owned **output** `auto-company-icon.svg` for its visible product icon and +favicon, for example ``. During delivery/capture the collector writes the validated +or default bytes to that canonical file in the declared `webRoot`, matching the +Dashboard asset. For Node profiles, declare the actual public asset root (for +example `public` for Vite). The coding model must leave the canonical output to +the program. This narrowly scoped generated-icon write happens before version +hashing. For supported static `.html`/`.htm` entries with one explicit ``, +the collector also checks the favicon reference. If no usable favicon exists, +it inserts one `` before ``, marked +`data-auto-company-icon="v1"`. Its href is relative to the HTML entry: +`auto-company-icon.svg` at the Web root or `../auto-company-icon.svg` for a +one-level nested entry, preserving file previews and deployment subpaths. It preserves +all original bytes and business DOM; correct references are left unchanged. +Existing validated user SVG favicons are preserved and reported as not unified +when their bytes differ from the Dashboard icon. Raster favicons, which this SVG +validator cannot verify, are preserved with an unconfirmed reference. External or conditional choices, +`` URLs, dynamic Node entries, unsupported encodings and implicit/malformed +heads are left untouched with explicit unconfirmed/unsupported diagnostics. +Title/script/style text never counts as an icon element. Self-closing/unclosed +text elements, business content before the head, premature head-ending tokens +and head template/noscript blocks fail closed without writing or claiming linkage. +Head scripts containing `<` in their text and nonstandard closing tokens such as +`` or `` are also unsupported; empty external-script elements +remain supported. The collector deliberately does not implement HTML5's complex +script parsing modes. +Modified owned references are not overwritten. No reference is inserted unless +the canonical file is available and validated. An existing conflicting +canonical file is preserved, with `publicationStatus: "conflict"`. Previously +program-owned output is only replaced while its bytes still match its ownership +record; a subsequent manual edit is preserved too. `publicationStatus` describes +file publication; `reference` separately describes the static entry's favicon +linkage. This is source evidence, not a guarantee about later JavaScript changes +or the browser's favicon selection. Capture/linkage failures remain independent +of functional checks and AI-cycle completion. + +Accepted SVGs have a finite `viewBox`, at most 64 simple shape/group elements and +32 KiB. Scripts, event handlers, external resources, style sheets, entities, +animation, foreign objects and text are rejected. A valid existing product icon +is reused; otherwise a deterministic geometric default is stored with +`source: "default"` and the reason. This validates safe static structure, not +aesthetic quality. No dedicated image model is called. + +## Artifacts and read APIs + +`product_media.capture_product(root, project, cycle_id=None, trigger="cycle", +readonly=False, retry=False)` is a write/execute operation. It requires an +existing stable product identity, refuses `readonly=True`, and uses a kernel +lock. Concurrent capture returns `capture_busy` without starting another worker. +Because Windows byte locks and Linux file locks do not interoperate on shared +filesystems, a one-time atomic `writer.json` binding limits a managed media folder +to its original Windows/POSIX writer family (`nt`/`posix`). A capture from the other OS fails with +`writer_runtime_mismatch`; read-only display continues to work in either OS. +Use the original runtime for retries; copying an archive does not authorize a +different execution environment to take ownership of its media writes. + +The explicit operator retry command is: + +```sh +python3 scripts/core/runtime_artifacts.py --project projects/example media --retry +``` + +Run it through the product's normal runtime (WSL for a WSL-owned product). It does +not invoke an AI model. Repeated automatic triggers reuse the same product, +content and capture-steps version; failed versions require explicit retry or a +source/configuration change. A source change during capture cannot be published +as success. Captures stay outside product source; only the declared canonical +icon and the narrowly scoped static-head metadata insertion are written there. +Read-only archives cannot execute either write. + +`media_projection(root, project, readonly=False)` is a pure read. Its `screenshot` +includes `state`, `reason`, `currentVersion`, `stale`, `attemptedAt` and +`latestSuccess`. States are `missing`, `capturing`, `interrupted`, `success`, +`failed`, `stale`, `unsupported` and `not_applicable`. `latestSuccess` contains +`version`, `cycleId`, `capturedAt`, `pagePath`, `attemptId` and two `variants` +(`viewport`, `width`, `height`, `sha256`, `name`, `href`). `icon` contains `source` +(`product` or `default`), `sha256`, `name`, `href`, `reason`, `recordedAt`, +`publicationStatus` (`published`, `existing_valid`, `conflict`, `failed`, `stale`, +`unavailable` or `not_applicable`) and, where relevant, +`publicationReason`/`publishedPath`. Pure reads verify previously published bytes +and expose subsequent edits or missing canonical files without repairing them. +`icon.reference` contains `state`, `reason`, `unified`, `managed` and, when known, +`href`, `entryPath` and `entrySha256`. Its states are `inserted`, `linked`, +`preserved`, `missing`, `not_applicable`, `unsupported`, `unconfirmed`, `conflict` +and `failed`. Only confirmed matching bytes have `unified: true`; preserved +different user icons have `false`; unsupported/unknown states remain `null`. +GET projections re-inspect the source and never insert or repair a reference. + +`read_resource(root, stable_id, name)` returns `(bytes, mime)` or `None`. The HTTP +adapter serves `/api/product-media//.png|svg`. Only +registered, digest-validated current images and safe SVGs are readable. Raw paths, +traversal, symlinks, modified files and unregistered files are rejected. Serve SVG +as an image with `nosniff`; do not inject it into the document. + +Generated captures and their metadata live in `logs/product-media//`. +The declared canonical product icon and the bounded static-head metadata link +are the only changes written into product source. +`media.json` is +committed atomically after both image files; `attempt-.json` records each +attempt. Failure preserves the latest intact success and its original timestamp. +Reads compute source freshness but do not repair records or start captures. +All distinct delivery images and the five most recent ordinary captures are +retained; older ordinary images and replaced icons are pruned. Attempt/cycle +metadata is retained. Product version hashing is bounded to 2,048 supported source +files and 24 MiB, excluding dependency, hidden and generated check trees. diff --git a/scripts/media/package-lock.json b/scripts/media/package-lock.json new file mode 100644 index 00000000..aaf268ba --- /dev/null +++ b/scripts/media/package-lock.json @@ -0,0 +1,45 @@ +{ + "name": "auto-company-product-media", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "auto-company-product-media", + "version": "1.0.0", + "dependencies": { + "playwright": "1.63.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/scripts/media/package.json b/scripts/media/package.json new file mode 100644 index 00000000..ecc4ba4b --- /dev/null +++ b/scripts/media/package.json @@ -0,0 +1,12 @@ +{ + "name": "auto-company-product-media", + "private": true, + "version": "1.0.0", + "description": "Optional bounded product screenshot runtime", + "engines": { + "node": ">=20" + }, + "dependencies": { + "playwright": "1.63.0" + } +} diff --git a/tests/browser/fixture-server.py b/tests/browser/fixture-server.py index 01c6c55a..a00654ae 100644 --- a/tests/browser/fixture-server.py +++ b/tests/browser/fixture-server.py @@ -50,7 +50,7 @@ def main(): shutil.copytree(REPO_ROOT / "dashboard", root / "dashboard") core = root / "scripts/core" core.mkdir(parents=True) - for name in ("localization.py", "usage_lib.py", "cycle_reports.py", "project_metadata.py"): + for name in ("localization.py", "usage_lib.py", "cycle_reports.py", "project_metadata.py", "product_identity.py", "product_media.py", "product_media_process.py", "product_icon_html.py"): shutil.copy2(REPO_ROOT / "scripts/core" / name, core / name) (root / "memories").mkdir() (root / "memories/consensus.md").write_text( diff --git a/tests/browser/journal.spec.js b/tests/browser/journal.spec.js index a5e4ee4f..14a4b78f 100644 --- a/tests/browser/journal.spec.js +++ b/tests/browser/journal.spec.js @@ -228,6 +228,84 @@ test("failed latest cycle never inherits an earlier consensus as its results", a await expect(page.locator("#currentCycle")).not.toContainText("已完成当前轮次验证"); }); +test("product timeline keeps five product cycles visible and folds exploration into its own records", async ({ page, journal }) => { + const data = await (await page.request.get(`${journal.url}/api/journal`)).json(); + const template = data.cycles[0]; + const cycle = (id, number, identityKind, status, summary, projectStatus = "current") => ({ + ...template, id, number, identityKind, numbering: "persistent", projectStatus, status, + active: false, summary, report: summary, workReport: null, workReportStatus: null, + startedAt: new Date(Date.now() - number * 60000).toISOString(), + endedAt: new Date(Date.now() - number * 60000 + 30000).toISOString(), + }); + const products = [5, 4, 3, 2, 1].map((number) => cycle(`product-${number}`, number, "product", "completed", `Product record ${number}`)); + const exploration = [ + cycle("explore-3", 3, "exploration", "completed", "Exploration report 3"), + cycle("explore-2", 2, "exploration", "failed", "Exploration report 2"), + cycle("explore-1", 1, "exploration", "unknown", "Exploration report 1", "unknown"), + ]; + const legacy = cycle("legacy-9", 9, undefined, "unknown", "Legacy unknown record", "unknown"); + legacy.numbering = "legacy"; + delete legacy.identityKind; + data.cycles = [...products, ...exploration, legacy]; + data.latestProjectCycleId = "product-5"; + data.cycleNumbering = { mode: "persistent", hasLegacy: true }; + data.language = "en"; + data.languageState = null; + await page.route("**/api/journal", (route) => route.fulfill({ json: data })); + await page.goto(`${journal.url}/journal`); + + await expect(page.locator("#cycleNumber")).toHaveText("05"); + await expect(page.locator("#historyList .history-number")).toHaveText(["04", "03", "02", "01"]); + await expect(page.locator("#historyList .history-row[open]")).toHaveCount(0); + await expect(page.locator("#olderButton")).toContainText("1 cycle"); + await expect(page.locator("#historyList")).not.toContainText("Exploration report"); + + const explorationDisclosure = page.locator("#explorationSection > details"); + await expect(explorationDisclosure).not.toHaveAttribute("open", ""); + await expect(explorationDisclosure.locator(":scope > summary")).toContainText("Pre-product exploration records"); + await explorationDisclosure.locator(":scope > summary").click(); + await expect(explorationDisclosure).toHaveAttribute("open", ""); + await expect(page.locator("#explorationSection .history-number")).toHaveText(["EXP 03", "EXP 02", "EXP 01"]); + await expect(page.locator("#explorationSection .history-row > summary .progress-completed")).toHaveCount(1); + await expect(page.locator("#explorationSection .history-row > summary .progress-failed")).toHaveCount(1); + await expect(page.locator("#explorationSection .history-row > summary .progress-unknown")).toHaveCount(1); + const explorationRow = page.locator("#explorationSection .history-row").first(); + await explorationRow.locator(":scope > summary").click(); + await expect(explorationRow).toHaveAttribute("open", ""); + await expect(explorationRow.locator(".history-content")).toContainText("Exploration report 3"); + await expect(explorationRow.locator(".cycle-log-link")).toBeVisible(); + + await page.locator("#refreshButton").click(); + await expect(explorationDisclosure).toHaveAttribute("open", ""); + await expect(explorationRow).toHaveAttribute("open", ""); + + await page.locator("#olderButton").click(); + const legacyRow = page.locator('#historyList .history-row[data-cycle-id="legacy-9"]'); + await expect(legacyRow).toBeVisible(); + await expect(legacyRow.locator(".progress-unknown")).toHaveCount(1); + await legacyRow.locator(":scope > summary").click(); + await expect(legacyRow.locator(".history-content")).toContainText("Legacy unknown record"); + await page.setViewportSize({ width: 360, height: 900 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); +}); + +test("exploration-only archives keep the original current and history timeline", async ({ page, journal }) => { + const data = await (await page.request.get(`${journal.url}/api/journal`)).json(); + data.cycles = data.cycles.map((cycle, index) => ({ + ...cycle, identityKind: "exploration", projectStatus: "current", + summary: `Exploration-only record ${3 - index}`, report: `Exploration-only record ${3 - index}`, + })); + data.latestProjectCycleId = data.cycles[0].id; + data.language = "en"; + data.languageState = null; + await page.route("**/api/journal", (route) => route.fulfill({ json: data })); + await page.goto(`${journal.url}/journal`); + await expect(page.locator("#cycleNumber")).toHaveText("03"); + await expect(page.locator("#currentCycle")).toContainText("Pre-product exploration"); + await expect(page.locator("#historyList .history-number")).toHaveText(["02", "01"]); + await expect(page.locator("#explorationSection")).toBeHidden(); +}); + test("cycle timeline preserves disclosures, keyboard focus and selected logs across changed data", async ({ page, journal }) => { await page.goto(`${journal.url}/journal`); diff --git a/tests/browser/media-reference-probe.cjs b/tests/browser/media-reference-probe.cjs new file mode 100644 index 00000000..dfd208f1 --- /dev/null +++ b/tests/browser/media-reference-probe.cjs @@ -0,0 +1,42 @@ +"use strict"; +const fs = require("node:fs"); +const path = require("node:path"); +const { createRequire } = require("node:module"); +let playwright; +for (const location of ["../../scripts/media/package.json", "./package.json"]) { + try { playwright = createRequire(path.resolve(__dirname, location))("playwright"); break; } + catch (error) { if (error.code !== "MODULE_NOT_FOUND") throw error; } +} + +async function inspect(page, html, fileUrl) { + if (fileUrl) await page.goto(fileUrl); + else await page.setContent(html); + return page.evaluate(() => ({ + title: document.title, + headIcons: document.head.querySelectorAll('link[rel~="icon"]').length, + bodyIcons: document.body.querySelectorAll('link[rel~="icon"]').length, + body: document.body.innerHTML, + href: document.head.querySelector('link[rel~="icon"]')?.getAttribute("href") || null, + absoluteHref: document.head.querySelector('link[rel~="icon"]')?.href || null, + })); +} + +(async () => { + const rows = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + const browser = await playwright.chromium.launch({ headless: true }); + const observations = []; + try { + for (const row of rows) { + const page = await browser.newPage(); + const before = await inspect(page, row.before); + const after = await inspect(page, row.after, row.fileUrl); + const resolved = await page.evaluate(({ href, fileUrl, deploymentUrl }) => href ? { + resolvedFile: new URL(href, fileUrl).href, + resolvedDeployment: new URL(href, deploymentUrl).href, + } : {}, { href: after.href, fileUrl: row.fileUrl, deploymentUrl: row.deploymentUrl }); + observations.push({ name: row.name, before, after, reference: row.reference, canonicalUrl: row.canonicalUrl, ...resolved }); + await page.close(); + } + } finally { await browser.close(); } + process.stdout.write(JSON.stringify(observations)); +})().catch(error => { process.stderr.write(String(error.stack || error)); process.exitCode = 1; }); diff --git a/tests/browser/showcase.spec.js b/tests/browser/showcase.spec.js new file mode 100644 index 00000000..70c268e8 --- /dev/null +++ b/tests/browser/showcase.spec.js @@ -0,0 +1,106 @@ +import { test as base, expect } from "@playwright/test"; +import http from "node:http"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projects = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../projects"); +const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml" }; +const test = base.extend({ + examples: async ({}, use) => { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, "http://127.0.0.1"); + const pathname = decodeURIComponent(url.pathname); + const file = path.resolve(projects, `.${pathname}${pathname.endsWith("/") ? "index.html" : ""}`); + if (!file.startsWith(`${projects}${path.sep}`)) { response.writeHead(403).end(); return; } + const data = await fs.readFile(file); + response.writeHead(200, { "Content-Type": `${types[path.extname(file)] || "application/octet-stream"}; charset=utf-8` }).end(data); + } catch { response.writeHead(404).end(); } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { await use(`http://127.0.0.1:${server.address().port}`); } + finally { await new Promise((resolve) => server.close(resolve)); } + }, + page: async ({ page }, use) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await use(page); + expect(errors, "Example JavaScript errors").toEqual([]); + }, +}); + +test("Text Meter counts actual Unicode input and resets through its controls", async ({ page, examples }) => { + await page.goto(`${examples}/text-meter/`); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + await page.locator("#text-input").fill("One 😀\nTwo"); + await expect(page.locator("#character-count")).toHaveText("9"); + await expect(page.locator("#non-whitespace-count")).toHaveText("7"); + await expect(page.locator("#word-count")).toHaveText("3"); + await expect(page.locator("#line-count")).toHaveText("2"); + await page.locator("#clear-button").click(); + await expect(page.locator("#text-input")).toBeEmpty(); + await expect(page.locator("#word-count")).toHaveText("0"); + await page.locator("#sample-button").click(); + await expect(page.locator("#text-input")).not.toBeEmpty(); + await expect(page.locator("#word-count")).not.toHaveText("0"); + await page.setViewportSize({ width: 360, height: 800 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); +}); + +for (const decision of ["approved", "declined"]) { + test(`ScopeFence returns the ${decision} link and handles same-tab navigation`, async ({ page, examples }) => { + await page.goto(`${examples}/scopefence/`); + await page.getByLabel("Project name").fill("Website revision"); + await page.getByLabel("What changed?").fill("Add the agreed pricing comparison section."); + await page.getByLabel("Price impact (USD)").fill("0"); + await page.getByLabel("Timeline impact (days)").fill("0"); + await page.getByRole("button", { name: "Create decision link" }).click(); + const draft = await page.locator('[data-copy-label="Copy link"]').getAttribute("data-copy"); + await page.goto(draft); + await expect(page.locator("#decision-heading")).toBeVisible(); + await page.locator(`[data-decision="${decision}"]`).click(); + await page.locator("#confirm-decision").click(); + const returned = await page.locator("#final-link").inputValue(); + await page.goto(`${examples}/scopefence/`); + await page.goto(returned); + await expect(page.locator("h1")).toHaveText(`This link says: ${decision}.`); + await expect(page.locator("body")).toContainText("Anyone with the link can alter its encoded data"); + await page.getByRole("link", { name: "ScopeFence home" }).click(); + await expect(page.locator("#receipt-form")).toBeVisible(); + await page.goto(`${examples}/scopefence/#r=not-valid-json`); + await expect(page.locator("#receipt-form")).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); + }); +} + +test("Chinese scope sheet blocks incomplete export and downloads actual form values", async ({ page, examples, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page.goto(`${examples}/scope-sheet/`); + await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN"); + await expect(page.locator("#doc-price")).toHaveText("¥ —"); + await page.locator("#download-button").click(); + await expect(page.locator("#action-message")).toContainText("请先"); + await page.locator("#example-button").click(); + await expect(page.locator("#doc-price")).toHaveText("¥ 2,800"); + await page.locator('[name="project"]').fill("中文验收项目"); + await page.locator('[name="project"]').press("Enter"); + await expect(page.locator('[name="project"]')).toHaveValue("中文验收项目"); + await expect(page.locator("#doc-project")).toContainText("中文验收项目"); + await page.locator("#copy-button").click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toContain("中文验收项目"); + const download = page.waitForEvent("download"); + await page.locator("#download-button").click(); + const file = await download; + expect(file.suggestedFilename()).toContain("中文验收项目"); + const text = await fs.readFile(await file.path(), "utf8"); + expect(text).toContain("中文验收项目"); + expect(text).toContain("¥ 2,800"); + await page.locator('[name="price"]').fill("-1"); + await expect(page.locator("#doc-price")).toHaveText("¥ —"); + await page.locator("#copy-button").click(); + await expect(page.locator("#action-message")).toContainText("报价"); + await page.setViewportSize({ width: 360, height: 800 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); +}); diff --git a/tests/test_auto_loop_integration.sh b/tests/test_auto_loop_integration.sh index f012d802..f48411b7 100755 --- a/tests/test_auto_loop_integration.sh +++ b/tests/test_auto_loop_integration.sh @@ -110,6 +110,7 @@ cp "$SOURCE_ROOT/scripts/core/rotate-logs.py" "$FRAMEWORK/scripts/core/" cp "$SOURCE_ROOT/scripts/core/consensus-guard.sh" "$FRAMEWORK/scripts/core/" cp "$SOURCE_ROOT/scripts/core/consensus-format.py" "$FRAMEWORK/scripts/core/" cp "$SOURCE_ROOT/scripts/core/project-context.py" "$FRAMEWORK/scripts/core/" +cp "$SOURCE_ROOT/scripts/core/product_identity.py" "$FRAMEWORK/scripts/core/" cp "$SOURCE_ROOT/scripts/core/localization.py" "$FRAMEWORK/scripts/core/" cp "$SOURCE_ROOT/scripts/core/ui-messages.sh" "$FRAMEWORK/scripts/core/" cp "$SOURCE_ROOT/i18n/messages.json" "$FRAMEWORK/i18n/" diff --git a/tests/test_ci_policy.py b/tests/test_ci_policy.py index e96cecd9..4bfe111e 100644 --- a/tests/test_ci_policy.py +++ b/tests/test_ci_policy.py @@ -57,6 +57,11 @@ def test_existing_shell_contract_applies_outside_runtime(self): self.assertEqual(self.selected("projects/snapog/check.sh"), {"runtime", "snapog"}) self.assertEqual(self.selected("docs/example.sh"), {"runtime"}) + def test_optional_product_media_dependencies_and_tests_select_real_browser_checks(self): + for path in ("scripts/media/package.json", "scripts/media/package-lock.json", "tests/test_product_media.py"): + with self.subTest(path=path): + self.assertEqual(self.selected(path), {"runtime", "browser"}) + def test_unknown_code_defaults_to_all_and_product_prefix_is_exact(self): for path in ("new-build/config.toml", "projects/snapog-copy/code.js", "pyproject.toml"): with self.subTest(path=path): diff --git a/tests/test_dashboard_frontend.js b/tests/test_dashboard_frontend.js index 169712cd..72bdc4b6 100644 --- a/tests/test_dashboard_frontend.js +++ b/tests/test_dashboard_frontend.js @@ -21,7 +21,7 @@ function helpers() { // copied application logic, network request or production testing hook. const binding = app.indexOf("\n document.querySelectorAll('[data-tab]').forEach"); assert.ok(binding > 0, "Journal event wiring must follow its helper declarations"); - vm.runInContext(app.slice(0, binding) + "\n globalThis.journal = { state, message, aggregate, duration, cycleTitle, statusLabel, formatTime, filterUsage, reportRows, liveDuration, checkCounts, checkPresentation, progressState, latestCycle, unavailableArtifact };\n})();", context); + vm.runInContext(app.slice(0, binding) + "\n globalThis.journal = { state, message, aggregate, duration, cycleTitle, statusLabel, formatTime, filterUsage, reportRows, liveDuration, checkCounts, checkPresentation, progressState, latestCycle, historyGroups, unavailableArtifact, mediaURL, mediaRetryError, iconPublicationWarning };\n})();", context); context.journal.state.language = "en"; return { ...context.journal, messages: context.window.JOURNAL_MESSAGES, fields }; } @@ -39,6 +39,43 @@ test("both languages cover rendered keys and preserve interpolation fields", () for (const [, key] of keys) assert.ok(messages.en[key], `Missing rendered translation: ${key}`); }); +test("media links must belong to the exact managed product", () => { + const { mediaURL } = helpers(); + const id = 'a'.repeat(32); + const url = `/api/product-media/${id}/${'b'.repeat(64)}.png`; + assert.equal(mediaURL(url, id), url); + for (const value of [url.replace(id, 'c'.repeat(32)), 'https://example.com/image.png', 'javascript:alert(1)', `/api/product-media/${id}/../private.svg`, `${url}?path=private`]) assert.equal(mediaURL(value, id), null); +}); + +test("unconfirmed starts never become completed cycles", () => { + const { cycleTitle, progressState, messages } = helpers(); + assert.equal(cycleTitle({status: 'startup_unconfirmed'}), messages.en.startup_unconfirmed); + assert.equal(progressState('startup_unconfirmed'), 'unknown'); + assert.equal(progressState('not_started'), 'pending'); +}); + +test("media retry errors expire after a new success or product change", () => { + const { state, mediaRetryError } = helpers(); + state.mediaError = {productId: 'a', previousSuccess: 'old'}; + assert.equal(mediaRetryError({productId: 'a', screenshot: {state: 'failed'}}), true); + assert.equal(mediaRetryError({productId: 'a', screenshot: {state: 'success', latestSuccess: {capturedAt: 'new'}}}), false); + assert.equal(state.mediaError, null); + state.mediaError = {productId: 'a', previousSuccess: null}; + assert.equal(mediaRetryError({productId: 'b', screenshot: {state: 'failed'}}), false); +}); + +test("canonical icon publication failures stay visible independently of source", () => { + const { iconPublicationWarning } = helpers(); + assert.equal(iconPublicationWarning({source: 'default', publicationStatus: 'published'}), null); + assert.equal(iconPublicationWarning({source: 'product', publicationStatus: 'existing_valid'}), null); + assert.equal(iconPublicationWarning({source: 'product', publicationStatus: 'conflict'}), 'iconPublicationConflict'); + assert.equal(iconPublicationWarning({source: 'default', publicationStatus: 'failed'}), 'iconPublicationFailed'); + for (const publicationStatus of ['stale', 'unavailable']) assert.equal(iconPublicationWarning({source: 'product', publicationStatus}), 'iconPublicationChanged'); + assert.equal(iconPublicationWarning({reference: {state: 'preserved'}}), 'iconReferencePreserved'); + for (const state of ['missing', 'unsupported', 'unconfirmed', 'conflict', 'failed']) assert.equal(iconPublicationWarning({reference: {state}}), 'iconReferenceUnconfirmed'); + for (const state of ['inserted', 'linked', 'not_applicable']) assert.equal(iconPublicationWarning({reference: {state}}), null); +}); + test("usage totals keep unknown values and coverage separate from measured zero", () => { const { aggregate } = helpers(); const totals = aggregate([ @@ -169,6 +206,26 @@ test("project selection never promotes unrelated or unknown history into current assert.equal(latestCycle({ cycles: [old] }), old); }); +test("product timelines separate exploration without dropping legacy or unknown records", () => { + const { historyGroups } = helpers(); + const current = { id: "product-5", identityKind: "product" }; + const product = { id: "product-4", identityKind: "product" }; + const exploration = { id: "explore-3", identityKind: "exploration", status: "failed" }; + const legacy = { id: "legacy", numbering: "legacy", status: "unknown" }; + const grouped = historyGroups({ cycles: [current, product, exploration, legacy] }, current); + assert.deepEqual(Array.from(grouped.main, (cycle) => cycle.id), ["product-4", "legacy"]); + assert.deepEqual(Array.from(grouped.exploration, (cycle) => cycle.id), ["explore-3"]); + + const explorationCurrent = { id: "explore-4", identityKind: "exploration" }; + const explorationOnly = historyGroups({ cycles: [explorationCurrent, exploration] }, explorationCurrent); + assert.deepEqual(Array.from(explorationOnly.main, (cycle) => cycle.id), ["explore-3"]); + assert.deepEqual(Array.from(explorationOnly.exploration), []); + + const currentExplorationWithProduct = historyGroups({ cycles: [explorationCurrent, product, exploration] }, explorationCurrent); + assert.deepEqual(Array.from(currentExplorationWithProduct.main, (cycle) => cycle.id), ["product-4"]); + assert.deepEqual(Array.from(currentExplorationWithProduct.exploration, (cycle) => cycle.id), ["explore-3"]); +}); + test("compact checks never promote stale, incomplete or failed evidence to success", () => { const { checkPresentation } = helpers(); diff --git a/tests/test_dashboard_journal.py b/tests/test_dashboard_journal.py index 21ba66fe..de0ca26f 100644 --- a/tests/test_dashboard_journal.py +++ b/tests/test_dashboard_journal.py @@ -64,6 +64,62 @@ def live_status(self): class JournalTests(JournalFixture): + def test_persistent_numbers_replace_only_their_exact_program_bound_cycles(self): + from product_identity import reserve_cycle, update_cycle + self.write("projects/probe/index.html", "

Real file

") + self.write(".auto-company.local", "ACTIVE_PROJECT=projects/probe\n") + first = reserve_cycle(self.root, "projects/probe", "attempt-a", 1) + update_cycle(self.root, first["cycleId"], "completed") + second = reserve_cycle(self.root, "projects/probe", "attempt-b", 1) + update_cycle(self.root, second["cycleId"], "completed") + self.ledger(record(first["cycleId"], project="projects/probe"), + record(second["cycleId"], project="projects/probe"), record()) + before = (self.root / "logs/usage.jsonl").read_bytes() + data = self.source.snapshot() + rows = {cycle["id"]: cycle for cycle in data["cycles"]} + self.assertEqual([rows[row["cycleId"]]["number"] for row in (first, second)], [1, 2]) + self.assertEqual(rows[second["cycleId"]]["runCycleNumber"], 1) + self.assertEqual(rows["cycle-0001-run-a"]["numbering"], "legacy") + self.assertEqual(data["project"]["stableId"], second["productId"]) + self.assertEqual((self.root / "logs/usage.jsonl").read_bytes(), before) + + def test_unconfirmed_reservation_is_visible_without_invented_usage_or_live_status(self): + from product_identity import reserve_cycle, update_cycle, recover_cycles + self.write("projects/probe/index.html", "

Real file

") + self.write(".auto-company.local", "ACTIVE_PROJECT=projects/probe\n") + cycle = reserve_cycle(self.root, "projects/probe", "uncertain", 1) + update_cycle(self.root, cycle["cycleId"], "dispatching") + recover_cycles(self.root) + row = self.source.snapshot()["cycles"][0] + self.assertEqual(row["status"], "startup_unconfirmed") + self.assertFalse(row["active"]) + self.assertIsNone(row["usage"]["totalTokens"]) + self.assertFalse(row["durationReliable"]) + + def test_creation_links_exploration_explicitly_without_relabeling_its_number(self): + from product_identity import reserve_cycle, register_project, update_cycle + cycle = reserve_cycle(self.root, "", "exploration", 1) + self.write("projects/probe/index.html", "

Real file

") + register_project(self.root, "projects/probe", cycle["cycleId"]) + update_cycle(self.root, cycle["cycleId"], "completed") + import hashlib + text = "# Real delivery file\n" + self.write("projects/probe/DELIVERY.md", text) + self.write("logs/artifacts/" + "a" * 32 + ".json", json.dumps({ + "version": 1, "id": "a" * 32, "kind": "document", "project": "projects/probe", + "cycleId": cycle["cycleId"], "recordedAt": "2026-09-19T01:00:00+00:00", "source": "runner", + "path": "projects/probe/DELIVERY.md", "sha256": hashlib.sha256(text.encode()).hexdigest()})) + data = self.source.snapshot() + self.assertEqual(data["project"]["id"], "projects/probe") + row = data["cycles"][0] + self.assertEqual(row["identityKind"], "exploration") + self.assertIsNone(row["stableProductId"]) + self.assertEqual(row["linkedProductId"], data["project"]["stableId"]) + self.assertEqual(row["projectStatus"], "current") + self.assertEqual(data["artifactCollection"]["selectedProject"], "projects/probe") + self.assertEqual(row["artifacts"][0]["path"], "projects/probe/DELIVERY.md") + self.assertEqual(data["artifacts"][0]["path"], "projects/probe/DELIVERY.md") + def test_unique_id_preserves_restarted_numbers_and_ignores_prose_cycle_count(self): first = record() second = record("cycle-0001-run-b", started_at="2026-09-18T13:00:00+08:00") @@ -170,7 +226,7 @@ def test_project_metadata_is_identity_bound_and_never_reuses_consensus(self): project = self.source.snapshot()["project"] self.assertEqual(project, {"id": "projects/probe", "name": "Probe", "displayName": "Probe", "description": "Bound description", "source": "project_metadata", - "status": "recorded", "recordedAt": "2026-09-19T01:00:00+00:00"}) + "status": "recorded", "recordedAt": "2026-09-19T01:00:00+00:00", "stableId": None}) self.assertEqual(self.source.documents(), [], "legacy root delivery cannot cross product selection") self.write(".auto-company.local", "ACTIVE_PROJECT=projects/other\n") self.write("projects/other/README.md", "new selection") @@ -374,10 +430,10 @@ def close(): thread.join(3) self.addCleanup(close) - def request(self, path, method="GET", headers=None): + def request(self, path, method="GET", headers=None, body=None): connection = http.client.HTTPConnection("127.0.0.1", self.server.server_address[1], timeout=5) try: - connection.request(method, path, headers=headers or {}) + connection.request(method, path, body=body, headers=headers or {}) response = connection.getresponse() return response.status, response.read(), dict(response.getheaders()) finally: @@ -386,7 +442,7 @@ def request(self, path, method="GET", headers=None): def test_every_write_route_is_forbidden_and_source_unchanged(self): self.ledger(record()) before = {str(path.relative_to(self.root)): path.read_bytes() for path in self.root.rglob("*") if path.is_file()} - for path in ("/api/action/start", "/api/action/stop", "/api/language", "/api/journal", "/anything"): + for path in ("/api/action/start", "/api/action/stop", "/api/language", "/api/journal", "/api/product-media/capture", "/anything"): for method in ("POST", "PUT", "PATCH", "DELETE"): self.assertEqual(self.request(path, method)[0], 403) after = {str(path.relative_to(self.root)): path.read_bytes() for path in self.root.rglob("*") if path.is_file()} @@ -465,6 +521,50 @@ def close(): request = JournalHTTPTests.request + def test_registered_media_is_served_as_bytes_and_unknown_paths_are_rejected(self): + raw = b"\x89PNG\r\n\x1a\n\xff\x00" + path = "/api/product-media/" + "a" * 32 + "/" + "b" * 64 + ".png" + with mock.patch.object(data_module.JournalSource, "media_resource", return_value=(raw, "image/png")) as read: + code, body, headers = self.request(path) + self.assertEqual((code, body, headers["Content-Type"]), (200, raw, "image/png")) + self.assertEqual(headers["X-Content-Type-Options"], "nosniff") + read.assert_called_once_with("a" * 32, "b" * 64 + ".png") + self.assertEqual(self.request(path + "?path=private")[0], 400) + self.assertEqual(self.request("/api/product-media/../../.env")[0], 400) + + def test_media_retry_requires_idle_runtime_and_never_calls_start(self): + body = json.dumps({"productId": "a" * 32}) + headers = {"Content-Type": "application/json"} + with mock.patch.object(production, "capture_product_media", return_value={"ok": True}) as capture, \ + mock.patch.object(production, "run_dashboard_action", side_effect=AssertionError("Must not run models")): + self.runtime_status["parsed"]["loop"]["processState"] = "running" + self.assertEqual(self.request("/api/product-media/capture", "POST", headers, body)[0], 409) + capture.assert_not_called() + self.runtime_status["parsed"]["loop"]["processState"] = "stopped" + self.assertEqual(self.request("/api/product-media/capture", "POST", headers, body)[0], 200) + capture.assert_called_once_with("a" * 32) + self.assertEqual(self.request("/api/product-media/capture", "POST", headers, '{}')[0], 400) + + def test_start_cannot_race_with_a_manual_capture(self): + entered, finish = threading.Event(), threading.Event() + def capture(_product): + entered.set() + finish.wait(3) + return {"ok": True} + headers = {"Content-Type": "application/json"} + with mock.patch.object(production, "capture_product_media", side_effect=capture), \ + mock.patch.object(production, "run_dashboard_action", side_effect=AssertionError("No concurrent start")): + thread = threading.Thread(target=lambda: self.request("/api/product-media/capture", "POST", headers, json.dumps({"productId": "a" * 32}))) + thread.start() + try: + self.assertTrue(entered.wait(2)) + self.assertEqual(production.CONTROL_ACTION, "capture") + self.assertEqual(self.request("/api/action/start", "POST", headers, '{}')[0], 409) + finally: + finish.set() + thread.join(4) + self.assertEqual(production.CONTROL_ACTION, "") + def test_root_and_journal_aliases_use_current_dashboard(self): for route in ("/", "/index.html", "/journal", "/journal/", "/journal/index.html"): self.assertEqual(self.request(route)[1], (DASHBOARD / "index.html").read_text(encoding="utf-8").encode("utf-8")) diff --git a/tests/test_product_cycle_loop.py b/tests/test_product_cycle_loop.py new file mode 100644 index 00000000..5794fb0f --- /dev/null +++ b/tests/test_product_cycle_loop.py @@ -0,0 +1,203 @@ +"""Real POSIX loop/creation/stop integration with a local fake provider only.""" +import json +import os +from pathlib import Path +import platform +import shutil +import signal +import subprocess +import tempfile +import time +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +@unittest.skipUnless(platform.system() == "Linux", "POSIX loop exercised under Linux/WSL") +class ProductCycleLoopTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + shutil.copytree(ROOT / "scripts/core", self.root / "scripts/core") + shutil.copytree(ROOT / "i18n", self.root / "i18n") + shutil.copytree(ROOT / "memories", self.root / "memories") + shutil.copy2(ROOT / ".gitignore", self.root / ".gitignore") + (self.root / "PROMPT.md").write_text("Offline integration fixture.\n") + subprocess.run(["git", "init", "--initial-branch=main", str(self.root)], check=True, capture_output=True) + self.fake = self.root / "fake-engine" + self.fake.write_text('''#!/bin/bash +if [ "${1:-}" = "--version" ]; then exit 0; fi +count=0 +[ ! -f "$AUTO_COMPANY_ROOT/invocations" ] || count=$(cat "$AUTO_COMPANY_ROOT/invocations") +count=$((count + 1)) +printf '%s' "$count" > "$AUTO_COMPANY_ROOT/invocations" +printf '%s\\n' "$*" > "$AUTO_COMPANY_ROOT/prompt-$count" +if [ "${CREATE_PRODUCT:-0}" = 1 ] && [ "$count" = 1 ]; then + "$AUTO_COMPANY_ROOT/scripts/core/project.sh" new --name created-product +fi +printf '{"type":"result","subtype":"success","result":"Recorded offline invocation","usage":{"input_tokens":2,"output_tokens":2}}\\n' +if [ "${INTERRUPT_FIXTURE:-0}" = 1 ]; then + touch "$AUTO_COMPANY_ROOT/provider-running" + sleep 30 +fi +if [ "$count" -ge "${STOP_AFTER:-1}" ]; then touch "$AUTO_COMPANY_ROOT/.auto-loop-stop"; fi +''') + self.fake.chmod(0o755) + self.env = dict(os.environ, ENGINE="claude", CLAUDE_BIN=str(self.fake), + CLAUDE_PERMISSION_MODE="default", AUTO_COMPANY_LANGUAGE="en", + LOOP_INTERVAL="0", CYCLE_TIMEOUT_SECONDS="15", CYCLE_TERM_GRACE_SECONDS="1", + CYCLE_KILL_WAIT_SECONDS="1", MAX_LOGS="1") + for key in ("AUTO_COMPANY_CYCLE", "AUTO_COMPANY_CYCLE_ID", "AUTO_COMPANY_LOCK_PID", "ACTIVE_PROJECT"): + self.env.pop(key, None) + self.loop = self.root / "scripts/core/auto-loop.sh" + + def run_loop(self, **environment): + result = subprocess.run(["bash", str(self.loop)], env={**self.env, **environment}, capture_output=True, text=True, timeout=45) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return self.state() + + def state(self): + return json.loads((self.root / ".auto-company/product-state.json").read_text()) + + def create_selected(self): + tool = self.root / "scripts/core/project.sh" + for args in (("new", "--name", "test-product"), ("select", "--project", "test-product", "--confirm", "SELECT")): + result = subprocess.run(["bash", str(tool), *args], env={**self.env, "AUTO_COMPANY_ROOT": str(self.root)}, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_real_loop_resumes_1_2_then_3_and_rotates_logs(self): + self.create_selected() + self.run_loop(STOP_AFTER="2") + state = self.run_loop(STOP_AFTER="3", MODEL="changed-model") + rows = list(state["cycles"].values()) + self.assertEqual(sorted(row["productCycleNumber"] for row in rows), [1, 2, 3]) + self.assertEqual(sorted(row["runCycleNumber"] for row in rows), [1, 1, 2]) + self.assertEqual({row["state"] for row in rows}, {"completed"}) + self.assertEqual(len({row["identityId"] for row in rows}), 1) + self.assertLess(len(list((self.root / "logs").glob("cycle-*.log"))), 3) + self.assertIn("product cycle #3 (process-local attempt #1)", (self.root / "prompt-3").read_text()) + + def test_failed_preflight_allocates_no_cycle(self): + result = subprocess.run(["bash", str(self.loop)], env={**self.env, "USAGE_HARD_LIMIT_TOKENS": "invalid"}, capture_output=True, text=True, timeout=20) + self.assertNotEqual(result.returncode, 0) + self.assertIn("usage budget preflight failed", result.stdout) + self.assertFalse((self.root / ".auto-company/product-state.json").exists()) + self.assertFalse((self.root / "invocations").exists()) + + def test_creation_registration_failure_rolls_back_source_and_registry(self): + (self.root / "projects").mkdir() + registry = self.root / "projects/registry.tsv" + original = b"name\tpath\tlifecycle\tcreated_at_utc\n" + registry.write_bytes(original) + (self.root / ".auto-company").mkdir() + (self.root / ".auto-company/product-state.json").write_text("invalid state") + result = subprocess.run(["bash", str(self.root / "scripts/core/project.sh"), "new", "--name", "failed-project"], + env={**self.env, "AUTO_COMPANY_ROOT": str(self.root)}, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.root / "projects/failed-project").exists()) + self.assertEqual(registry.read_bytes(), original) + + def test_normal_creation_links_exploration_without_editing_selection(self): + self.run_loop(STOP_AFTER="1", CREATE_PRODUCT="1") + self.assertNotIn("ACTIVE_PROJECT=", (self.root / ".auto-company.local").read_text()) + state = self.run_loop(STOP_AFTER="2") + rows = list(state["cycles"].values()) + self.assertEqual(sorted(row["productCycleNumber"] for row in rows), [1, 1]) + product_id = state["continuationProductId"] + self.assertEqual(state["identities"][product_id]["project"], "projects/created-product") + prompt = (self.root / "prompt-2").read_text() + self.assertIn("projects/created-product", prompt) + self.assertIn("registered exploration continuation", prompt) + self.assertIn("product cycle #1", prompt) + + def test_stop_records_interruption_and_restart_does_not_reuse_number(self): + self.create_selected() + output = self.root / "loop.out" + with output.open("w") as stream: + loop = subprocess.Popen(["bash", str(self.loop)], env={**self.env, "INTERRUPT_FIXTURE": "1"}, stdout=stream, stderr=stream) + try: + deadline = time.monotonic() + 20 + while not (self.root / "provider-running").exists(): + if loop.poll() is not None or time.monotonic() > deadline: + self.fail(output.read_text()) + time.sleep(0.05) + loop.send_signal(signal.SIGTERM) + self.assertEqual(loop.wait(timeout=10), 0, output.read_text()) + finally: + if loop.poll() is None: + loop.terminate() + loop.wait(timeout=10) + self.assertEqual(next(iter(self.state()["cycles"].values()))["state"], "interrupted") + pause = self.root / ".auto-loop-paused" + self.assertIn("interrupted_cycle", pause.read_text()) + # Preserve the existing human review/resume gate after interruption. + pause.unlink() + state = self.run_loop(STOP_AFTER="2") + self.assertEqual(sorted(row["productCycleNumber"] for row in state["cycles"].values()), [1, 2]) + self.assertEqual({row["state"] for row in state["cycles"].values()}, {"interrupted", "completed"}) + + def slow_capture_stop(self, flag_only): + self.create_selected() + project = self.root / "projects/test-product" + (project / "index.html").write_text("
Offline media stop fixture
") + # Exercise the actual Python media supervisor and loop cancellation with + # a harmless long-lived renderer subtree; no browser or model is needed. + (self.root / "scripts/core/product_media_worker.cjs").write_text(''' +const fs = require("node:fs"), path = require("node:path"), {spawn} = require("node:child_process"); +const request = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const child = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], {stdio:"ignore"}); +fs.appendFileSync(path.join(request.project,".review-captures"), "capture\\n"); +fs.writeFileSync(path.join(request.project,".review-processes"), JSON.stringify([process.pid, child.pid])); +setInterval(()=>{},1000); +''') + output = self.root / "slow-capture.out" + with output.open("w") as stream: + process = subprocess.Popen(["bash", str(self.loop)], env={**self.env, "STOP_AFTER": "100", "LOOP_INTERVAL": "1"}, stdout=stream, stderr=stream) + try: + deadline = time.monotonic() + 20 + marker = project / ".review-processes" + while not marker.exists(): + if process.poll() is not None or time.monotonic() > deadline: + self.fail(output.read_text()) + time.sleep(0.05) + pids = json.loads(marker.read_text()) + self.assertEqual(next(iter(self.state()["cycles"].values()))["state"], "completed") + started = time.monotonic() + if flag_only: + (self.root / ".auto-loop-stop").touch() + else: + stopped = subprocess.run(["bash", str(self.root / "scripts/core/stop-loop.sh"), "--wait"], + capture_output=True, text=True, timeout=20) + self.assertEqual(stopped.returncode, 0, stopped.stdout + stopped.stderr) + self.assertEqual(process.wait(timeout=15), 0, output.read_text()) + self.assertLess(time.monotonic() - started, 15) + self.assertEqual((project / ".review-captures").read_text(), "capture\n") + self.assertEqual((self.root / "invocations").read_text(), "1") + self.assertEqual(next(iter(self.state()["cycles"].values()))["state"], "completed") + usage = [json.loads(line) for line in (self.root / "logs/usage.jsonl").read_text().splitlines()] + self.assertEqual([row["status"] for row in usage], ["completed"]) + manifests = list((self.root / "logs/product-media").glob("*/media.json")) + self.assertEqual(len(manifests), 1) + attempt = json.loads(manifests[0].read_text())["attempt"] + self.assertEqual(attempt["state"], "interrupted") + for pid in pids: + status = Path(f"/proc/{pid}/stat") + self.assertTrue(not status.exists() or status.read_text().split()[2] == "Z", f"media process {pid} survived") + self.assertFalse((self.root / ".auto-loop-paused").exists()) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=15) + + @unittest.skipUnless(shutil.which("node"), "Node runtime is required for the harmless renderer subtree") + def test_signal_during_slow_capture_cleans_scope_without_changing_completed_cycle(self): + self.slow_capture_stop(flag_only=False) + + @unittest.skipUnless(shutil.which("node"), "Node runtime is required for the harmless renderer subtree") + def test_stop_flag_during_slow_capture_cancels_without_starting_another_capture(self): + self.slow_capture_stop(flag_only=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_product_identity.py b/tests/test_product_identity.py new file mode 100644 index 00000000..a936263b --- /dev/null +++ b/tests/test_product_identity.py @@ -0,0 +1,210 @@ +"""Persistent numbering and explicit source registration, without model calls.""" +from concurrent.futures import ThreadPoolExecutor +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts/core")) +import product_identity as products + + +class ProductIdentityTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + for name in ("alpha", "beta"): + (self.root / "projects" / name).mkdir(parents=True) + + def reserve(self, attempt, project="projects/alpha", run=1): + return products.reserve_cycle(self.root, project, attempt, run, "codex", "fixture") + + def complete(self, row): + products.update_cycle(self.root, row["cycleId"], "dispatching") + products.update_cycle(self.root, row["cycleId"], "completed") + + def test_read_only_queries_create_nothing(self): + before = sorted(str(path.relative_to(self.root)) for path in self.root.rglob("*")) + self.assertIsNone(products.get_identity(self.root, "alpha")) + self.assertIsNone(products.cycle_projection(self.root, "legacy")) + self.assertEqual(products.list_cycle_projections(self.root)["cycles"], []) + self.assertEqual(products.continuation_project(self.root), "") + self.assertEqual(before, sorted(str(path.relative_to(self.root)) for path in self.root.rglob("*"))) + + def test_restart_model_change_and_log_rotation_do_not_reset(self): + first = self.reserve("run1-1") + self.complete(first) + second = self.reserve("run1-2", run=2) + self.complete(second) + (self.root / "logs").mkdir() + (self.root / "logs/old.log").write_text("discarded by retention") + shutil.rmtree(self.root / "logs") + third = products.reserve_cycle(self.root, "projects/alpha", "run2-1", 1, "claude", "other") + self.assertEqual([row["productCycleNumber"] for row in (first, second, third)], [1, 2, 3]) + self.assertEqual(third["runCycleNumber"], 1) + self.assertEqual(first["productId"], third["productId"]) + self.assertEqual(len(products.read_state(self.root)["cycles"]), 3) + + def test_duplicate_start_event_is_atomic_and_idempotent(self): + with ThreadPoolExecutor(max_workers=4) as executor: + results = list(executor.map(lambda _: self.reserve("same-attempt"), range(8))) + self.assertEqual(len({row["cycleId"] for row in results}), 1) + self.assertEqual(products.get_identity(self.root, "alpha")["lastCycleNumber"], 1) + with self.assertRaises(ValueError): + self.reserve("same-attempt", project="projects/beta") + + def test_unfinished_reservation_is_not_reused(self): + first = self.reserve("reserved") + with self.assertRaises(ValueError): + self.reserve("second") + self.assertEqual(products.recover_cycles(self.root)[0]["state"], "not_started") + self.assertEqual(products.recover_cycles(self.root), []) + next_row = self.reserve("new-attempt") + self.assertEqual(next_row["productCycleNumber"], 2) + self.assertNotEqual(first["cycleId"], next_row["cycleId"]) + + def test_dispatch_intent_does_not_claim_provider_execution(self): + row = self.reserve("uncertain") + products.update_cycle(self.root, row["cycleId"], "dispatching") + self.assertEqual(products.recover_cycles(self.root)[0]["state"], "startup_unconfirmed") + + def test_recorded_output_recovers_as_interrupted(self): + row = self.reserve("interrupted") + products.update_cycle(self.root, row["cycleId"], "dispatching") + (self.root / "logs").mkdir() + (self.root / "logs" / (row["cycleId"] + ".events.jsonl")).write_text('{"type":"thread.started"}\n') + self.assertEqual(products.recover_cycles(self.root)[0]["state"], "interrupted") + + def test_final_usage_commit_recovers_closed_cycle(self): + row = self.reserve("closed-before-crash") + products.update_cycle(self.root, row["cycleId"], "dispatching") + (self.root / "logs").mkdir() + (self.root / "logs/usage.jsonl").write_text(json.dumps({"kind": "cycle_usage", "cycle_id": row["cycleId"], "status": "completed"}) + "\n") + self.assertEqual(products.recover_cycles(self.root)[0]["state"], "completed") + + def test_products_have_independent_sequences(self): + alpha = self.reserve("alpha-1") + self.complete(alpha) + beta = self.reserve("beta-1", project="beta") + self.complete(beta) + alpha2 = self.reserve("alpha-2") + self.assertNotEqual(alpha["productId"], beta["productId"]) + self.assertEqual([alpha["productCycleNumber"], beta["productCycleNumber"], alpha2["productCycleNumber"]], [1, 1, 2]) + + def test_explicit_creation_links_exploration_and_continues_product(self): + exploration = self.reserve("exploration-1", project="") + products.update_cycle(self.root, exploration["cycleId"], "dispatching") + product = products.register_project(self.root, "alpha", exploration["cycleId"]) + self.assertEqual(products.continuation_project(self.root), "projects/alpha") + self.assertEqual(products.cycle_projects(self.root, exploration["cycleId"]), ["projects/alpha"]) + self.assertEqual(products.cycle_projection(self.root, exploration["cycleId"])["linkedProductId"], product["id"]) + with self.assertRaises(ValueError): + products.register_project(self.root, "beta", exploration["cycleId"]) + self.assertIsNone(products.get_identity(self.root, "beta")) + self.complete(exploration) + actual = self.reserve("product-1") + self.assertEqual(actual["productCycleNumber"], 1) + self.assertEqual(actual["explorationId"], exploration["identityId"]) + history = products.list_cycle_projections(self.root, product["id"])["cycles"] + self.assertEqual({row["kind"] for row in history}, {"exploration", "product"}) + self.assertFalse((self.root / ".auto-company.local").exists()) + + def test_pagination_and_product_filter_are_exact(self): + rows = [] + for index in range(5): + row = self.reserve(f"attempt-{index}") + self.complete(row) + rows.append(row) + first = products.list_cycle_projections(self.root, rows[0]["productId"], limit=2) + second = products.list_cycle_projections(self.root, rows[0]["productId"], limit=3, before=first["nextBefore"]) + self.assertEqual(len({row["cycleId"] for row in first["cycles"] + second["cycles"]}), 5) + self.assertEqual(first["total"], 5) + self.assertIsNone(second["nextBefore"]) + + def test_source_relocation_keeps_identity_and_sequence(self): + first = self.reserve("before-move") + self.complete(first) + (self.root / "projects/alpha").rename(self.root / "projects/renamed") + products.relocate_identity(self.root, first["productId"], "renamed") + second = self.reserve("after-move", project="renamed") + self.assertEqual(second["productId"], first["productId"]) + self.assertEqual(second["productCycleNumber"], 2) + + def test_reused_path_does_not_inherit_old_product(self): + original = self.reserve("original") + self.complete(original) + shutil.rmtree(self.root / "projects/alpha") + (self.root / "projects/alpha").mkdir() + self.assertIsNone(products.get_identity(self.root, "alpha")) + with self.assertRaises(ValueError): + self.reserve("unregistered-replacement") + products.register_project(self.root, "alpha") + replacement = self.reserve("replacement") + self.assertNotEqual(original["productId"], replacement["productId"]) + self.assertEqual(replacement["productCycleNumber"], 1) + + def test_failed_commit_rolls_back_registration_marker(self): + original_write = products.atomic_write + def fail_state(path, data): + if path.name == "product-state.json": + raise OSError("test write failure") + return original_write(path, data) + with patch.object(products, "atomic_write", side_effect=fail_state): + with self.assertRaises(OSError): + products.register_project(self.root, "alpha") + self.assertIsNone(products.get_identity(self.root, "alpha")) + self.assertFalse((self.root / "projects/alpha/.auto-company/identity.json").exists()) + self.assertFalse((self.root / ".auto-company/product-state.lock").exists()) + + def test_killed_writer_recovers_staged_reservation_without_reusing_number(self): + code = """ +import os, sys +from pathlib import Path +sys.path.insert(0, sys.argv[1]) +import product_identity as p +original = p.atomic_write +def crash(path, data): + if path.name == 'product-state.json': + os._exit(91) + return original(path, data) +p.atomic_write = crash +p.reserve_cycle(Path(sys.argv[2]), 'projects/alpha', 'crashed-writer', 1) +""" + crashed = subprocess.run([sys.executable, "-c", code, str(ROOT / "scripts/core"), str(self.root)], capture_output=True) + self.assertEqual(crashed.returncode, 91) + self.assertTrue((self.root / ".auto-company/product-state.lock").is_dir()) + restored = subprocess.run([sys.executable, str(ROOT / "scripts/core/product_identity.py"), + "--root", str(self.root), "recover-lock", "--confirm", "STOPPED"], capture_output=True, text=True) + self.assertEqual(restored.returncode, 0, restored.stderr) + recovered = products.recover_cycles(self.root) + self.assertEqual(len(recovered), 1) + self.assertEqual(recovered[0]["state"], "not_started") + identity = products.get_identity(self.root, "alpha") + following = self.reserve("after-crashed-writer") + self.assertEqual(following["productId"], identity["id"]) + self.assertEqual(following["productCycleNumber"], 2) + + def test_legacy_files_are_never_rewritten_or_reinterpreted(self): + (self.root / "logs").mkdir() + legacy = self.root / "logs/cycle-0001-old.json" + legacy.write_bytes(b'{"cycle_number":1,"result":"legacy"}\r\n') + before = legacy.read_bytes() + self.reserve("new-runtime") + self.assertIsNone(products.cycle_projection(self.root, "cycle-0001-old")) + self.assertEqual(legacy.read_bytes(), before) + + def test_corrupt_store_fails_closed_instead_of_resetting(self): + self.reserve("first") + (self.root / ".auto-company/product-state.json").write_text('{"schemaVersion":1}') + with self.assertRaises(ValueError): + self.reserve("second") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_product_media.py b/tests/test_product_media.py new file mode 100644 index 00000000..314c6311 --- /dev/null +++ b/tests/test_product_media.py @@ -0,0 +1,730 @@ +"""Media boundaries plus opt-in real Chromium/static/Node preview acceptance.""" +import json +import http.client +from http.server import BaseHTTPRequestHandler, HTTPServer +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch +import zlib + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts/core")) +from product_identity import get_identity, reserve_cycle +from product_media import (MediaError, VIEWPORTS, atomic_json, capture_lock, capture_product, + content_version, digest, load_profile, media_folder, media_projection, + now, read_resource, validate_icon) +from runtime_artifacts import finalize +from product_media_process import ProcessScope, proc_identity +from product_icon_html import inspect_reference + +HEAD_REVIEW_CASES = { + "title_pseudo_link": 'Example <link rel="icon" href="/auto-company-icon.svg">
Business
', + "self_closing_title": 'Product</head><body><main>Business</main></body></html>', + "leading_business_text": '<!doctype html>Business prefix<html><head><title>Product
Business
', + "stray_end_br": 'Product
Business
', + "script_double_escaped": '