Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dim-background-task-status-wording.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": patch
---

Dim the wording on background task status lines so only the status dot is coloured.
72 changes: 67 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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})`)
: '');
Expand Down
180 changes: 180 additions & 0 deletions apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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, '');

Check warning on line 10 in apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
}

describe('BackgroundAgentStatusComponent', () => {
Expand Down Expand Up @@ -45,6 +47,46 @@
);
});

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',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

// 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',
Expand Down
Loading
Loading