diff --git a/.changeset/dim-background-task-status-wording.md b/.changeset/dim-background-task-status-wording.md new file mode 100644 index 00000000..ceb28a61 --- /dev/null +++ b/.changeset/dim-background-task-status-wording.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Dim the wording on background task status lines so only the status dot is coloured. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 161c2b61..5242f7e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -204,10 +204,70 @@ jobs: if-no-files-found: error # code.pythinker.com redeploys via Dokploy autodeploy on push to main (app - # Pythinker/code builds apps/site/Dockerfile from the repo), so no deploy - # webhook is fired here — this job verifies that the published release is - # internally consistent and that the CDN is not advertising a version npm - # does not have. + # Pythinker/code builds apps/site/Dockerfile from the repo). That autodeploy + # fires on the `ci: release packages` push — which STARTS the release — while + # apps/site/scripts/build-cdn.mjs reads the version from npm's dist-tag, which + # only moves when the publish FINISHES. The first build therefore bakes in the + # previous version and nothing rebuilds it, so the release stays invisible to + # every installed client. This job fires a second deploy after the publish. + # + # It must run after publish-native-assets: latest.json only gets its + # per-platform `platforms` block once the native zips exist on the release. + # That job is itself conditional and SKIPS on an npm-only release, and a job + # whose `needs` includes a skipped job is skipped too — hence `always()`, and + # hence the explicit upstream result assertions it forces us to spell out. + redeploy-cdn: + timeout-minutes: 10 + name: Redeploy CDN + needs: + - release + - publish-native-assets + if: >- + always() + && needs.release.result == 'success' + && needs.publish-native-assets.result != 'failure' + && needs.publish-native-assets.result != 'cancelled' + && (needs.release.outputs.packages_published == 'true' + || startsWith(github.event.head_commit.message, 'ci: release packages')) + runs-on: ubuntu-latest + steps: + - name: Trigger Dokploy rebuild + env: + WEBHOOK: ${{ secrets.DOKPLOY_CDN_DEPLOY_WEBHOOK }} + run: | + if [ -z "$WEBHOOK" ]; then + echo "::warning::DOKPLOY_CDN_DEPLOY_WEBHOOK not set — skipping CDN redeploy." + exit 0 + fi + # The URL is itself the deploy credential, so never send it over a + # scheme that puts it on the wire in cleartext. Warn rather than fail: + # verify-cdn-release runs only if this job succeeds, and failing here + # would drop the consistency gate instead of tripping it. + case "$WEBHOOK" in + https://*) ;; + *) + echo "::warning::DOKPLOY_CDN_DEPLOY_WEBHOOK is not an https:// URL — refusing to send the deploy credential in cleartext." + exit 0 + ;; + esac + # The webhook matches the branch from the request body: a bare POST + # answers 301 {"message":"Branch Not Match"} and deploys nothing. + # + # A transient failure must never fail the workflow. npm has already + # published by now and that is irreversible, so dying here buys + # nothing — an earlier version of this job was deleted because a + # curl exit-28 timeout failed the 0.5.0 release. verify-cdn-release + # polls the manifest and is the gate that fails loudly. `--fail` is + # what makes an HTTP error status reach the retries and the warning + # instead of exiting 0 and reading as a successful deploy. + curl -sS --fail -X POST "$WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d '{"ref":"refs/heads/main"}' \ + --retry 3 --retry-all-errors --retry-delay 10 --max-time 60 \ + || echo "::warning::CDN redeploy webhook failed — verify-cdn-release will catch a stale CDN." + + # Verifies that the published release is internally consistent and that the + # CDN caught up with npm. It polls, so it must run after redeploy-cdn. # # It also runs on a `ci: release packages` merge that published nothing: that # commit bumps the version on main, so gating the check on a successful @@ -216,7 +276,9 @@ jobs: verify-cdn-release: timeout-minutes: 15 name: Verify release consistency - needs: release + needs: + - release + - redeploy-cdn if: >- needs.release.outputs.packages_published == 'true' || startsWith(github.event.head_commit.message, 'ci: release packages') diff --git a/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts b/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts index 3d968bad..994f4fbb 100644 --- a/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts @@ -15,17 +15,22 @@ export class BackgroundAgentStatusComponent implements Component { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; - const tone: keyof ColorPalette = + // Only the bullet carries the status. A background task is ambient — it is + // not what the user asked for — so the wording stays dim and the eye picks + // the line out by colour of the dot alone, never by a fully coloured line. + const bulletTone: keyof ColorPalette = this.data.phase === 'started' - ? 'primary' + ? 'textDim' : this.data.phase === 'completed' ? 'success' : 'error'; const bullet = - this.data.phase === 'failed' ? currentTheme.fg(tone, FAILURE_MARK) : currentTheme.fg(tone, STATUS_BULLET); + this.data.phase === 'failed' + ? currentTheme.fg(bulletTone, FAILURE_MARK) + : currentTheme.fg(bulletTone, STATUS_BULLET); const text = - currentTheme.fg(tone, this.data.headline) + + currentTheme.fg('textDim', this.data.headline) + (this.data.detail !== undefined && this.data.detail.length > 0 ? currentTheme.fg('textDim', ` (${this.data.detail})`) : ''); diff --git a/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts b/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts new file mode 100644 index 00000000..1c483e25 --- /dev/null +++ b/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; + +import { + classifyCdnVersion, + compareRelease, + pollCdnUntilCaughtUp, +} from '../../../../../scripts/release/cdn-consistency.mjs'; + +const URL = 'https://cdn.example/latest.json'; + +/** Response stub shaped like the subset of `fetch` the poll actually reads. */ +function manifest(version: unknown, ok = true, status = 200) { + return { + ok, + status, + text: async () => JSON.stringify({ version }), + }; +} + +/** + * Fake clock and sleep: `sleep` advances the clock instead of waiting, so a + * ten-minute budget resolves instantly and the test asserts real elapsed logic. + */ +function fakeClock() { + let current = 0; + return { + now: () => current, + sleep: async (ms: number) => { + current += ms; + }, + }; +} + +/** Serve one scripted outcome per attempt. */ +function scriptedFetch(steps: readonly (() => unknown)[]) { + let index = 0; + return async () => { + const step = steps[Math.min(index, steps.length - 1)]; + index += 1; + return step?.(); + }; +} + +describe('compareRelease', () => { + it('orders by major, then minor, then patch', () => { + const parse = (value: string) => /^(\d+)\.(\d+)\.(\d+)$/u.exec(value) as RegExpExecArray; + expect(compareRelease(parse('1.0.0'), parse('0.9.9'))).toBeGreaterThan(0); + expect(compareRelease(parse('0.13.0'), parse('0.12.0'))).toBeGreaterThan(0); + expect(compareRelease(parse('0.12.1'), parse('0.12.2'))).toBeLessThan(0); + expect(compareRelease(parse('0.12.0'), parse('0.12.0'))).toBe(0); + }); + + it('separates identifiers that float arithmetic would round together', () => { + const parse = (value: string) => /^(\d+)\.(\d+)\.(\d+)$/u.exec(value) as RegExpExecArray; + // 9007199254740992 and 9007199254740993 are the same IEEE-754 double. + expect(compareRelease(parse('9007199254740993.0.0'), parse('9007199254740992.0.0'))).toBe(1); + expect(compareRelease(parse('0.9007199254740992.0'), parse('0.9007199254740993.0'))).toBe(-1); + }); +}); + +describe('classifyCdnVersion', () => { + it('reports an equal version as a match', () => { + expect(classifyCdnVersion('0.13.0', '0.13.0')).toBe('match'); + }); + + it('reports an older CDN version as behind', () => { + expect(classifyCdnVersion('0.12.0', '0.13.0')).toBe('behind'); + expect(classifyCdnVersion('0.13.0', '1.0.0')).toBe('behind'); + }); + + it('reports a newer CDN version as ahead', () => { + expect(classifyCdnVersion('0.14.0', '0.13.0')).toBe('ahead'); + }); + + it('rejects anything that is not a stable release version', () => { + expect(classifyCdnVersion('not-a-version', '0.13.0')).toBe('invalid'); + expect(classifyCdnVersion('0.13.0-beta.1', '0.13.0')).toBe('invalid'); + expect(classifyCdnVersion('', '0.13.0')).toBe('invalid'); + expect(classifyCdnVersion(undefined, '0.13.0')).toBe('invalid'); + }); +}); + +describe('pollCdnUntilCaughtUp', () => { + const base = { url: URL, npmLatest: '0.13.0', budgetMs: 600_000, intervalMs: 15_000 }; + + it('resolves on the first attempt when the CDN already matches', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + now, + sleep, + fetchImpl: scriptedFetch([() => manifest('0.13.0')]), + }); + + expect(result).toMatchObject({ ok: true, reason: 'match', cdnVersion: '0.13.0', attempts: 1 }); + }); + + it('keeps polling while the CDN is behind and succeeds once it catches up', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + now, + sleep, + fetchImpl: scriptedFetch([ + () => manifest('0.12.0'), + () => manifest('0.12.0'), + () => manifest('0.13.0'), + ]), + }); + + expect(result).toMatchObject({ ok: true, reason: 'match', attempts: 3 }); + }); + + it('treats an unreachable CDN as lag rather than a failure', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + now, + sleep, + fetchImpl: scriptedFetch([ + () => { + throw new Error('ECONNREFUSED'); + }, + () => manifest('0.13.0', false, 502), + () => ({ ok: true, status: 200, text: async () => 'not json' }), + () => manifest('0.13.0'), + ]), + }); + + expect(result).toMatchObject({ ok: true, reason: 'match', attempts: 4 }); + }); + + it('fails immediately when the CDN is ahead of npm', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + now, + sleep, + fetchImpl: scriptedFetch([() => manifest('0.14.0')]), + }); + + expect(result).toMatchObject({ + ok: false, + reason: 'ahead', + cdnVersion: '0.14.0', + attempts: 1, + }); + }); + + it('gives up with the last observed version once the budget expires', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + budgetMs: 45_000, + now, + sleep, + fetchImpl: scriptedFetch([() => manifest('0.12.0')]), + }); + + expect(result).toMatchObject({ ok: false, reason: 'timeout', cdnVersion: '0.12.0' }); + expect(result.attempts).toBe(3); + }); + + it('reports a null version when the CDN was never readable', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + budgetMs: 15_000, + now, + sleep, + fetchImpl: scriptedFetch([ + () => { + throw new Error('ENOTFOUND'); + }, + ]), + }); + + expect(result).toMatchObject({ ok: false, reason: 'timeout', cdnVersion: null }); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts b/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts index fbc2efbc..22ed9f0f 100644 --- a/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts @@ -1,8 +1,10 @@ import { visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; import { describe, expect, it } from 'vitest'; import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -45,6 +47,46 @@ describe('BackgroundAgentStatusComponent', () => { ); }); + it('colours only the bullet by phase and keeps the wording dim', () => { + const started = new BackgroundAgentStatusComponent({ + phase: 'started', + headline: 'bash task started in background', + detail: 'E2E: contained brand mark', + }); + const completed = new BackgroundAgentStatusComponent({ + phase: 'completed', + headline: 'bash task completed in background', + detail: 'E2E: contained brand mark · exit 0', + }); + + // Colours are off by default under vitest, which would make every + // assertion below compare bare strings and pass for the wrong reason. + const previousLevel = chalk.level; + chalk.level = 3; + try { + const startedLine = started.render(120).join('\n'); + const completedLine = completed.render(120).join('\n'); + + // A running task is ambient: dim dot, dim wording, no accent colour. + expect(startedLine).toContain(currentTheme.fg('textDim', STATUS_BULLET)); + expect(startedLine).toContain(currentTheme.fg('textDim', 'bash task started in background')); + expect(startedLine).not.toContain( + currentTheme.fg('primary', 'bash task started in background'), + ); + + // Completion turns the dot green — and only the dot. + expect(completedLine).toContain(currentTheme.fg('success', STATUS_BULLET)); + expect(completedLine).toContain( + currentTheme.fg('textDim', 'bash task completed in background'), + ); + expect(completedLine).not.toContain( + currentTheme.fg('success', 'bash task completed in background'), + ); + } finally { + chalk.level = previousLevel; + } + }); + it('keeps status lines within very narrow widths', () => { const component = new BackgroundAgentStatusComponent({ phase: 'started', diff --git a/scripts/release/cdn-consistency.mjs b/scripts/release/cdn-consistency.mjs new file mode 100644 index 00000000..35e2d244 --- /dev/null +++ b/scripts/release/cdn-consistency.mjs @@ -0,0 +1,99 @@ +/** + * Pure CDN-versus-npm consistency logic for the release gate. + * + * The CDN manifest is built from npm's dist-tag, but the site rebuild is + * triggered by the push that *starts* the release — several minutes before the + * publish that moves the dist-tag. The first build therefore advertises the + * previous version and nothing rebuilds it on its own, so the release stays + * invisible to every installed client. The pipeline fires a redeploy after the + * publish and then polls here until the manifest catches up. + * + * Everything is dependency-injected (fetch, sleep, clock) so the gate is unit + * testable without a network or a real wait. + */ + +/** Stable release semver. The CDN manifest never advertises a prerelease. */ +const RELEASE_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; + +/** + * Numeric major/minor/patch compare over two RELEASE_SEMVER matches. + * + * BigInt rather than Number: semver puts no ceiling on an identifier, and two + * distinct versions past 2^53 would round to the same float and compare equal — + * reporting a stale or impossible CDN as a match. + */ +export function compareRelease(left, right) { + for (let index = 1; index <= 3; index += 1) { + const a = BigInt(left[index]); + const b = BigInt(right[index]); + if (a !== b) return a < b ? -1 : 1; + } + return 0; +} + +/** + * Where the CDN sits relative to the npm dist-tag. + * + * 'ahead' means clients would be pointed at a release that does not exist; + * 'behind' is ordinary deploy lag; 'invalid' means the manifest is unusable. + */ +export function classifyCdnVersion(cdnVersion, npmLatest) { + const cdnMatch = typeof cdnVersion === 'string' ? cdnVersion.match(RELEASE_SEMVER) : null; + const npmMatch = typeof npmLatest === 'string' ? npmLatest.match(RELEASE_SEMVER) : null; + if (cdnMatch === null || npmMatch === null) return 'invalid'; + const diff = compareRelease(cdnMatch, npmMatch); + if (diff === 0) return 'match'; + return diff > 0 ? 'ahead' : 'behind'; +} + +/** + * Read the version the CDN manifest currently advertises. + * + * Throws on transport, status and parse failures alike; the caller treats all + * three the same way, so they are deliberately not distinguished here. + */ +async function readCdnVersion(fetchImpl, url) { + const response = await fetchImpl(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = JSON.parse(await response.text()); + return typeof body.version === 'string' ? body.version : null; +} + +/** + * Poll the CDN manifest until it advertises `npmLatest`. + * + * A fetch failure, a non-ok status and an unparseable manifest are all treated + * exactly like 'behind': mid-deploy the origin is legitimately unreachable or + * half-written, and both that and plain lag resolve by waiting, so only the + * budget decides. 'ahead' returns at once — more waiting cannot fix a manifest + * that names a release npm does not have. + */ +export async function pollCdnUntilCaughtUp(options) { + const { fetchImpl, sleep, now, url, npmLatest, budgetMs, intervalMs } = options; + const deadline = now() + budgetMs; + let cdnVersion = null; + let attempts = 0; + + for (;;) { + attempts += 1; + let classification = 'unreachable'; + try { + const observed = await readCdnVersion(fetchImpl, url); + if (observed !== null) { + cdnVersion = observed; + classification = classifyCdnVersion(observed, npmLatest); + } + } catch { + // Deliberately swallowed: an unreachable CDN is lag, not a gate failure. + } + + if (classification === 'match') return { ok: true, reason: 'match', cdnVersion, attempts }; + if (classification === 'ahead') return { ok: false, reason: 'ahead', cdnVersion, attempts }; + + // Stop before a sleep that would run past the budget rather than after it. + if (now() + intervalMs >= deadline) { + return { ok: false, reason: 'timeout', cdnVersion, attempts }; + } + await sleep(intervalMs); + } +} diff --git a/scripts/release/verify-release-consistency.mjs b/scripts/release/verify-release-consistency.mjs index 5956a736..03e287de 100644 --- a/scripts/release/verify-release-consistency.mjs +++ b/scripts/release/verify-release-consistency.mjs @@ -1,25 +1,24 @@ import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; +import { pollCdnUntilCaughtUp } from './cdn-consistency.mjs'; + const PACKAGE_NAME = '@pythoughts/pythinker-code'; const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; const CDN_MANIFEST_URL = 'https://code.pythinker.com/pythinker-code/latest.json'; +// A Dokploy rebuild serves the new manifest in roughly two minutes. The budget +// stays well under the job's own timeout-minutes so a stale CDN is reported +// here rather than killed by the runner. +const CDN_POLL_BUDGET_MS = 600_000; +const CDN_POLL_INTERVAL_MS = 15_000; + function fail(reason) { console.error(`consistency failed: ${reason}`); process.exit(1); } -/** Numeric major/minor/patch compare over two SEMVER regex matches. */ -function compareRelease(left, right) { - for (let index = 1; index <= 3; index += 1) { - const diff = Number(left[index]) - Number(right[index]); - if (diff !== 0) return diff; - } - return 0; -} - let localVersion; let distTags; @@ -60,31 +59,39 @@ try { } if (!gitTags.trim().split('\n').includes(releaseTag)) fail(`missing git tag ${releaseTag}`); -// The CDN manifest is what every installed client polls for updates, so a -// version it advertises that npm does not have sends all of them into an install -// that cannot succeed. Ahead of npm is a hard failure; behind is deploy lag, -// since the site rebuilds on the next push to main. -let cdnVersion; -try { - const response = await fetch(CDN_MANIFEST_URL, { signal: AbortSignal.timeout(15_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - cdnVersion = JSON.parse(await response.text()).version; -} catch (error) { - console.warn(`warning: cannot read the CDN manifest (${error.message}) — CDN check skipped`); -} +// The CDN manifest is what every installed client polls for updates. A version +// it advertises that npm does not have sends all of them into an install that +// cannot succeed; a version it never catches up to hides the release entirely. +// The pipeline triggers a rebuild before this job, so both are hard failures — +// waiting it out is the whole point of the poll. +const cdnPoll = await pollCdnUntilCaughtUp({ + fetchImpl: (url) => fetch(url, { signal: AbortSignal.timeout(15_000) }), + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + now: () => Date.now(), + url: CDN_MANIFEST_URL, + npmLatest: distTags.latest, + budgetMs: CDN_POLL_BUDGET_MS, + intervalMs: CDN_POLL_INTERVAL_MS, +}); -if (typeof cdnVersion === 'string' && cdnVersion !== distTags.latest) { - const cdnMatch = cdnVersion.match(SEMVER); - if (!cdnMatch) fail(`CDN manifest version is not semver: ${cdnVersion}`); - if (compareRelease(cdnMatch, latestMatch) > 0) { - fail( - `CDN advertises ${cdnVersion} but npm latest is ${distTags.latest} — ` + - 'clients would try to install a release that does not exist', - ); - } - console.log( - `CDN is behind npm (cdn=${cdnVersion} latest=${distTags.latest}); it catches up on the next push to main`, +if (cdnPoll.reason === 'ahead') { + fail( + `CDN advertises ${cdnPoll.cdnVersion} but npm latest is ${distTags.latest} — ` + + 'clients would try to install a release that does not exist', ); } +if (!cdnPoll.ok) { + fail( + `CDN never caught up with npm within ${CDN_POLL_BUDGET_MS / 1000}s ` + + `(cdn=${cdnPoll.cdnVersion ?? 'unreachable'} latest=${distTags.latest}, ` + + `${cdnPoll.attempts} attempts) — every installed client polls this manifest, ` + + 'so the release stays invisible until the site rebuilds', + ); +} + +console.log(`CDN matches npm (${cdnPoll.cdnVersion}) after ${cdnPoll.attempts} attempt(s)`); console.log(`consistency OK: latest=${distTags.latest} beta=${distTags.beta ?? '-'} dev=${distTags.dev ?? '-'}`);