Compare commits

..
Author SHA1 Message Date
shadow-testandClaude Opus 5 3741e0fef5 Number releases by the highest one already published, not by drift
The Linux release upload failed with a bare "exitcode '1'" and no output.
The cause was not the upload: compute-version handed it a version that
had already been released three days earlier.

The patch number was `git rev-list --count <highest tag>..HEAD` — how far
HEAD has drifted from whichever tag sorts highest, which resets to zero
every time a tag is cut. It is not a counter, and the published history
is what the old formula returned at each point:

  v0.4.0 -> 3 commits -> v0.4.3    looked fine
  v0.4.3 -> 4 commits -> v0.4.4    fine by luck, 4 > 3
  v0.4.4 -> 2 commits -> v0.4.2    went backwards
  v0.4.4 -> 6 commits -> v0.4.6    jumped, skipping .5
  v0.4.6 -> 3 commits -> v0.4.3    already taken

So the line published 0.4.0, 0.4.3, 0.4.4, 0.4.2, 0.4.6 in that order,
never used 0.4.1 or 0.4.5, and then came back round to 0.4.3.

The patch is now one past the highest already used. Suffixed tags count
towards that: create-tag is skipped whenever a platform job fails, so a
run can publish v0.4.7-mac and never create the plain v0.4.7, and reading
only unsuffixed tags would hand the same number out twice. A commit that
is already tagged reuses its own tag, so re-running a build does not mint
a version.

Reusing a number was doing real damage, not just failing. macOS and
Windows delete-then-upload each asset, so they took the duplicate in
their stride and rewrote v0.4.3-mac and v0.4.3-win — public since
Aug 11 — with today's binaries. Linux is the only platform that failed,
and failing was the correct outcome; its v0.4.3 assets are the only ones
still original.

Linux also gets the idempotent get-or-create the other two already had,
plus `set -euo pipefail` and `-fsS`. Its `curl -s` with no `-f` is why a
409 produced no diagnostic at all: the HTTP error was swallowed, the id
grep came back empty, and the step died without ever printing why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 22:57:36 -07:00
jknapp 0e6566d903 Merge pull request 'Fix Playwright setup destroying its own install, and the migration notice that stayed silent' (#25) from fix/playwright-container-setup into main
Build Container / build-container (push) Successful in 57s
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m37s
Build App / build-windows (push) Successful in 5m45s
Build App / build-linux (push) Failing after 5m18s
Build App / create-tag (push) Skipped
Build App / sync-to-github (push) Skipped
2026-08-14 04:34:20 +00:00
shadow-testandClaude Opus 5 84a67fcd0d Stop an empty base-image label from silencing the migration notice
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 1m5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-linux (pull_request) Successful in 5m31s
Build App (Preview) / build-windows (pull_request) Successful in 6m24s
Build App (Preview) / prune-previews (pull_request) Successful in 8s
A project can be out of date and say nothing about it, in two ways that
compound: the lineage lookup treats "unknown" as an answer, and the
fallback that exists for unknown lineage disappears when its probe fails.

`create_container` always writes triple-c.base-image-id, even when the
value is unknown — deliberately, so an inherited image label cannot ride
a snapshot forever. That makes Some("") the ordinary reading from a
container whose lineage was never established. The lookup filtered for
emptiness only on the final result, so that empty string satisfied the
container branch and skipped the snapshot entirely: a snapshot that had
recorded a real lineage was never consulted, and the project reported
"unknown" with the answer one lookup away. Each source is now filtered
before it can answer, in pick_recorded_lineage, which is a plain function
so the case has a test that fails against the old logic.

A genuinely pre-label project stays unknown, and should: its ancestor is
not knowable, and inventing one would make it look permanently current.
The probe is the intended signal for those — but if the probe failed,
get_container_staleness returned early with nothing populated, the banner
found no gaps and rendered null, and the probe_error it already knew how
to display sat behind a gate that returned before reaching it. Silence
there is indistinguishable from "up to date", and it is likeliest for the
oldest and largest projects, whose manifests are the ones apt to exceed
the inspection limit — one real project measured 6.93 MB against an 8 MB
cap. An unknown-lineage container whose probe failed now says the check
could not be completed, with the reason, under the tone that means
unresolved rather than the one that means something is wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:24:51 -07:00
shadow-testandClaude Opus 5 f3cc1c4c17 Stop Playwright setup from deleting the package it just installed
Setting up the browser view failed on every container, and re-running it
reproduced the same broken state, because the setup destroyed its own work.

`install_packages` ran two `npm install --no-save` commands into
/workspace, which has no package.json. With no manifest, npm treats the
command line as the whole statement of what the tree should contain and
prunes the rest, so installing `playwright` second removed the
`@playwright/cli` installed first: "removed 3 packages", leaving an empty
node_modules/@playwright/ behind playwright and playwright-core. That
empty directory is exactly what the pane then reported as missing. The
second install now names both specs; the first one is already present, so
it costs nothing and is only there to stop npm pruning it.

Two failures were waiting behind that one:

Nothing in the tree ever configured the browser, so playwright-cli fell
back to channel `chrome` — system Google Chrome — with the Chromium
sandbox on. These containers forbid unprivileged user namespaces, so it
aborted with "Failed to move to new namespace ... Operation not
permitted"; on a base image without Google Chrome the same default failed
as "Chromium distribution 'chrome' is not found". entrypoint.sh now seeds
~/.playwright/cli.config.json on every start, which is the only way to
reach existing projects: ~/.playwright is inside the home volume, so an
image copy would reach new projects only.

The launch check passed for a configuration the viewer never uses. It
launched bundled chromium with no channel, which resolves to
chromium-headless-shell, while the viewer's config pins
chrome-for-testing — the full chromium build, a separate download. A
container could pass every check and still fail in the pane with 'Browser
"chrome-for-testing" is not installed', which is what a stale
chromium-1217 against a wanted chromium-1237 did. Chromium is now
verified on both channels, the sandbox setting is stated rather than
inherited from a default, and a failure names the channel.

triple-c-playwright-heal repairs all of it on a container that is already
broken, including the missing socat that makes the pane report
"127.0.0.1 sent an invalid response" while the container side is
perfectly healthy. It verifies by launching a browser rather than
trusting the preceding steps — which is how the stale-revision case was
found — and lives in /usr/local/bin so a fix to it can still reach an
existing project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:24:37 -07:00
jknapp 7265f55f27 Merge pull request 'Fix scheduled tasks failing to authenticate, and show when one is running' (#24) from fix/scheduler-home-clobber into main
Build Container / build-container (push) Successful in 1m52s
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 5m40s
Build App / build-linux (push) Successful in 7m30s
Build App / create-tag (push) Successful in 11s
Build App / sync-to-github (push) Successful in 17s
Reviewed-on: #24
2026-08-12 13:55:54 +00:00
jknapp 88f2e73474 Merge branch 'main' into fix/scheduler-home-clobber
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 34s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-linux (pull_request) Canceled after 0s
Build App (Preview) / prune-previews (pull_request) Canceled after 0s
Build App (Preview) / build-macos (pull_request) Canceled after 21s
Build App (Preview) / build-windows (pull_request) Canceled after 22s
2026-08-12 13:55:37 +00:00
shadow-testandClaude Opus 5 fa4940dd7d Say when a scheduled task is running
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build Container / build-container (pull_request) Successful in 2m53s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m2s
Build App (Preview) / build-linux (pull_request) Successful in 6m53s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
A run is detached — cron has no terminal, and the app fires it as a detached
exec — so triggering one and watching the log was indistinguishable from
triggering one that died. Worse, `claude -p` writes its answer in a single
burst at the end, so a healthy run shows nothing but its log header for as
long as it is thinking. The honest reading of the old UI was "it stalled".

triple-c-task-runner now publishes a state file per run (pid, start time, log
path) and removes it from an EXIT trap. flock remains what actually prevents
overlapping runs; this is purely observability, so every reader verifies the
pid rather than trusting the file — a container stopped mid-run cannot fire a
trap, and a task stuck on "running" forever would be a worse lie than no
indicator at all. Stale files are cleared on read.

On top of that:

- `list` grows a status column: "running 4m12s" or "idle".
- `status [--id] [--watch]` answers "is it still going?" directly, with
  elapsed time and the tail of the log when there is any output yet.
- `run` streams the log instead of blocking silently, and refuses to start a
  task that is already running.
- The Automation tab marks a running task, disables its Run now button, and
  polls while anything is in flight — including the second or two between
  firing a run and the runner registering it, which is the exact window that
  used to read as dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:33:49 -07:00
shadow-testandClaude Opus 5 9027fa9ad4 Stop the scheduler handing Claude root's HOME
Build Container / build-container (pull_request) Successful in 1m11s
Every scheduled task failed with "Not logged in · Please run /login" while
the container's OAuth credential sat there, valid, the whole time.

The entrypoint snapshots the environment into ~/.claude/scheduler/.env so
cron jobs get more than cron's minimal env. It runs as root, and HOME was
in the capture list, so the file recorded HOME=/root. The task runner then
sources that file with `set -a`, overwriting the HOME cron gave the job.
`claude -p` looks for its credential under $HOME, finds no /root/.claude,
and exits 1. Logging still worked — SCHEDULER_DIR is expanded before the
sourcing — which is why this presents as a well-formed log of a task that
never authenticated.

Drop HOME from the captured set and write it explicitly instead; cron does
still need one. Then restore HOME across the source in the task runner too:
.env lives on the home volume, so every project created before this ships
keeps a stale copy of it until its container restarts, and the runner is
what has to survive that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:13:59 -07:00
jknapp be37723c38 Merge pull request 'Sweep the snapshot commits recreation leaves behind' (#23) from sweep-orphaned-snapshots into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m44s
Build App / build-linux (push) Successful in 6m18s
Build App / create-tag (push) Successful in 12s
Build App / sync-to-github (push) Successful in 12s
Reviewed-on: #23
2026-08-12 02:06:24 +00:00
18 changed files with 1021 additions and 75 deletions
+95 -11
View File
@@ -39,13 +39,48 @@ jobs:
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]') MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
echo "Major.Minor: ${MAJOR_MINOR}" echo "Major.Minor: ${MAJOR_MINOR}"
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes) # The patch number is **one past the highest patch already used**, and
# `|| true` so an empty grep result doesn't fail the step under pipefail. # never a distance.
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true) #
# It used to be `git rev-list --count <highest tag>..HEAD`, which is
# not a counter at all: it measures how far HEAD has drifted from
# whichever tag sorts highest, and that resets to zero every time a
# tag is cut. The published history is the proof — each of these is
# exactly what the old formula returned at the time:
#
# v0.4.0 -> 3 commits -> v0.4.3 looked fine
# v0.4.3 -> 4 commits -> v0.4.4 fine by luck, 4 > 3
# v0.4.4 -> 2 commits -> v0.4.2 went backwards
# v0.4.4 -> 6 commits -> v0.4.6 jumped, skipping .5
# v0.4.6 -> 3 commits -> v0.4.3 already taken; the upload failed
#
# Reusing a version is worse than failing to publish one: the macOS
# and Windows steps replace assets in place, so a duplicate silently
# rewrote a release that had been public for three days. Monotonic
# numbering is what stops that at the source.
#
# Suffixed tags count too. `create-tag` is skipped when any platform
# job fails, so a run can publish v0.4.7-mac and never create the
# plain v0.4.7 — reading only unsuffixed tags would then hand the
# same number out twice.
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
| sort -n | tail -1 || true)
if [ -n "$LATEST_TAG" ]; then # A re-run of a commit that already released must not mint a new
echo "Latest matching tag: ${LATEST_TAG}" # version just because its own tag now exists.
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD") EXISTING=$(git tag --points-at HEAD \
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
| sort -n | tail -1 || true)
if [ -n "$EXISTING" ]; then
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — reusing it"
PATCH="${EXISTING}"
elif [ -n "$HIGHEST" ]; then
echo "Highest patch already used on this line: ${HIGHEST}"
PATCH=$((HIGHEST + 1))
else else
# A minor line nobody has tagged yet is a *new* line, and a new line # A minor line nobody has tagged yet is a *new* line, and a new line
# starts at .0 — that is what "we are moving to 0.4.x" means. The # starts at .0 — that is what "we are moving to 0.4.x" means. The
@@ -165,21 +200,70 @@ jobs:
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
set -euo pipefail
TAG="v${{ needs.compute-version.outputs.version }}" TAG="v${{ needs.compute-version.outputs.version }}"
# Create release
curl -s -X POST \ # Idempotent get-or-create, matching build-macos. This step used to
# POST /releases unconditionally: against a tag that already existed
# Gitea answered 409, the grep below found no id, and the run died
# with a bare "exitcode '1'" and not one line of output explaining
# it — `curl -s` with no `-f` swallows the HTTP error, so nothing
# ever said "409" or "duplicate tag". Hence -fsS throughout, and
# pipefail so a failure cannot be stepped over.
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
case "${HTTP_CODE}" in
200)
echo "Release ${TAG} already exists, reusing"
;;
404)
echo "Creating release ${TAG}"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') ;;
*)
echo "Unexpected ${HTTP_CODE} looking up release ${TAG}:" >&2
cat release.json >&2
exit 1
;;
esac
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(open('release.json')).get('id',''))")
if [ -z "${RELEASE_ID}" ]; then
echo "No release id for ${TAG}; refusing to upload into nothing:" >&2
cat release.json >&2
exit 1
fi
echo "Release ID: ${RELEASE_ID}" echo "Release ID: ${RELEASE_ID}"
# Upload each artifact
# Replace-not-conflict, so a retry after a partial upload succeeds.
# Versions are monotonic now (see compute-version), so this can only
# ever be replacing an asset from a failed run of this same commit —
# never one belonging to an already-published version.
for file in artifacts/*; do for file in artifacts/*; do
[ -f "$file" ] || continue [ -f "$file" ] || continue
filename=$(basename "$file") filename=$(basename "$file")
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
echo "Uploading ${filename}..." echo "Uploading ${filename}..."
curl -s -X POST \ curl -fsS --http1.1 \
--retry 5 --retry-all-errors --retry-delay 5 \
--max-time 600 \
-X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@${file}" \ --data-binary "@${file}" \
+11 -1
View File
@@ -1139,13 +1139,23 @@ triple-c-scheduler list # List all tasks
triple-c-scheduler enable --id abc123 # Enable a task triple-c-scheduler enable --id abc123 # Enable a task
triple-c-scheduler disable --id abc123 # Disable a task triple-c-scheduler disable --id abc123 # Disable a task
triple-c-scheduler remove --id abc123 # Delete a task triple-c-scheduler remove --id abc123 # Delete a task
triple-c-scheduler run --id abc123 # Trigger a task immediately triple-c-scheduler run --id abc123 # Trigger a task now, streaming its log
triple-c-scheduler status # What is running right now, and for how long
triple-c-scheduler status --id abc123 -w # Watch one task until its run finishes
triple-c-scheduler logs --id abc123 # View logs for a task triple-c-scheduler logs --id abc123 # View logs for a task
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks) triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
triple-c-scheduler notifications # View completion notifications triple-c-scheduler notifications # View completion notifications
triple-c-scheduler notifications --clear # Clear notifications triple-c-scheduler notifications --clear # Clear notifications
``` ```
`list` carries a status column, and the Automation tab marks a task **Running** with
its elapsed time, so a triggered run is visible rather than silent.
Note that a log which has stopped growing is not evidence of a stall: `claude -p`
writes its answer in one go when it finishes, so a healthy run shows nothing but its
header for as long as it is thinking. `status` is what distinguishes a slow run from
a dead one — it reports the run only while the runner's process is genuinely alive.
### Cron Schedule Format ### Cron Schedule Format
Standard 5-field cron: `minute hour day-of-month month day-of-week` Standard 5-field cron: `minute hour day-of-month month day-of-week`
+74 -26
View File
@@ -194,11 +194,24 @@ impl BrowserTarget {
} }
} }
/// The `channel` a launch check must pass. `None` means the bundled build. /// Every `channel` a launch check must pass, comma-separated, where
fn channel(self) -> Option<&'static str> { /// `default` means "no channel — the bundled build".
///
/// Chromium is checked twice because the two consumers of this install do
/// not launch the same binary. A script calling `chromium.launch()` with
/// no channel gets `chromium-headless-shell`; the viewer reads
/// `~/.playwright/cli.config.json`, which pins channel
/// `chrome-for-testing`, and that resolves to the *full* `chromium-<rev>`
/// build — a separate download under the same `install chromium`.
///
/// Checking only the first is how a container reaches "verified" and then
/// fails in the pane with `Browser "chrome-for-testing" is not installed`.
/// Observed on a real project, where a stale `chromium-1217` satisfied the
/// headless-shell launch while the viewer wanted `chromium-1237`.
fn channels(self) -> &'static str {
match self { match self {
Self::Chromium => None, Self::Chromium => "default,chrome-for-testing",
Self::Chrome => Some("chrome"), Self::Chrome => "chrome",
} }
} }
} }
@@ -235,7 +248,7 @@ pub async fn install_packages(
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR), &format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
); );
let mut step = npm_install(app, project_id, container_id, VIEWER_PACKAGE).await?; let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?;
if step.exit_code != 0 { if step.exit_code != 0 {
return Err(format!( return Err(format!(
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}", "npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
@@ -246,9 +259,15 @@ pub async fn install_packages(
// Second, `playwright` at the version the viewer package pins — see // Second, `playwright` at the version the viewer package pins — see
// `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree. // `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree.
//
// The viewer package is named *again* here. It is already installed, so
// this adds no work, but omitting it is what made npm prune it back out —
// see the note on `npm_install`. The pin can only be read after the first
// install has written the manifest, which is why this stays two commands
// rather than one.
let spec = pinned_playwright_spec(container_id).await; let spec = pinned_playwright_spec(container_id).await;
emit_progress(app, project_id, &format!("Installing {}", spec)); emit_progress(app, project_id, &format!("Installing {}", spec));
let second = npm_install(app, project_id, container_id, &spec).await?; let second = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE, &spec]).await?;
if second.exit_code != 0 { if second.exit_code != 0 {
return Err(format!( return Err(format!(
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}", "npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
@@ -290,7 +309,7 @@ pub async fn install_packages(
}) })
} }
/// One `npm install` of one spec, into [`INSTALL_DIR`], as `claude`. /// One `npm install` of one or more specs, into [`INSTALL_DIR`], as `claude`.
/// ///
/// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in /// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
/// `docker/exec.rs` untouched, and `env` is a real binary so no shell is /// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
@@ -298,13 +317,24 @@ pub async fn install_packages(
/// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at /// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at
/// all), but if a future release brings the browser download back, this step /// all), but if a future release brings the browser download back, this step
/// must stay small and the download must stay the step the user asked for. /// must stay small and the download must stay the step the user asked for.
///
/// **Every package that must survive has to appear in `specs`.** `--no-save`
/// in a directory with no `package.json` — which [`INSTALL_DIR`] is — leaves
/// npm with the command line as its only statement of what the tree should
/// contain, and npm ≥7 reconciles the tree against that on every run by
/// removing whatever it now considers extraneous. Installing `@playwright/cli`
/// and then installing `playwright` in a second command therefore *deletes the
/// first one*: verified in a container, `removed 3 packages`, leaving an empty
/// `node_modules/@playwright/` behind `playwright` and `playwright-core`. That
/// empty directory is why a fresh setup could report success and still leave
/// the pane saying `@playwright/cli` was not installed.
async fn npm_install( async fn npm_install(
app: &AppHandle, app: &AppHandle,
project_id: &str, project_id: &str,
container_id: &str, container_id: &str,
spec: &str, specs: &[&str],
) -> Result<StepResult, String> { ) -> Result<StepResult, String> {
let cmd = vec![ let mut cmd = vec![
"env".to_string(), "env".to_string(),
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(), "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
"npm".to_string(), "npm".to_string(),
@@ -313,8 +343,8 @@ async fn npm_install(
"--no-save".to_string(), "--no-save".to_string(),
"--no-fund".to_string(), "--no-fund".to_string(),
"--no-audit".to_string(), "--no-audit".to_string(),
spec.to_string(),
]; ];
cmd.extend(specs.iter().map(|s| s.to_string()));
run_step( run_step(
app, app,
project_id, project_id,
@@ -704,7 +734,7 @@ async fn verify_launch(
], ],
vec![ vec![
format!("TRIPLE_C_PW_DIR={}", dir), format!("TRIPLE_C_PW_DIR={}", dir),
format!("TRIPLE_C_PW_CHANNEL={}", target.channel().unwrap_or("")), format!("TRIPLE_C_PW_CHANNELS={}", target.channels()),
format!("TRIPLE_C_PW_URL={}", REACHABILITY_URL), format!("TRIPLE_C_PW_URL={}", REACHABILITY_URL),
], ],
); );
@@ -795,27 +825,43 @@ fn parse_launch_output(output: &str) -> LaunchVerdict {
/// The launch check. One `argv` element, no newlines, same contract as the /// The launch check. One `argv` element, no newlines, same contract as the
/// detection probe. /// detection probe.
/// ///
/// Playwright leaves the Chromium sandbox disabled by default, which is what /// `chromiumSandbox` is set explicitly rather than left to Playwright's
/// makes this work in a container at all. The timeout exists so a browser that /// default, so this check states the same thing the seeded
/// hangs on a missing library still returns a verdict rather than sitting there /// `cli.config.json` does instead of agreeing with it by coincidence. The
/// until the exec is torn down. The navigation is best-effort and never decides /// containers forbid unprivileged user namespaces, so a sandboxed Chromium
/// `ok` — it exists to tell a TLS-intercepted network apart from a broken /// aborts on launch; nothing here should be able to drift back into testing a
/// install. /// configuration the viewer will not use.
///
/// Each channel in `TRIPLE_C_PW_CHANNELS` is launched in turn — see
/// [`BrowserTarget::channels`] for why Chromium needs two — and a failure
/// names the channel that failed, because "is not installed" is meaningless
/// without it. Only the last launch loads a page: the navigation is
/// best-effort, never decides `ok`, and exists to tell a TLS-intercepted
/// network apart from a broken install, so doing it once is enough.
///
/// The timeout exists so a browser that hangs on a missing library still
/// returns a verdict rather than sitting there until the exec is torn down.
const LAUNCH_PROBE: &str = concat!( const LAUNCH_PROBE: &str = concat!(
r#"const d=process.env.TRIPLE_C_PW_DIR,ch=process.env.TRIPLE_C_PW_CHANNEL||undefined,u=process.env.TRIPLE_C_PW_URL;"#, r#"const d=process.env.TRIPLE_C_PW_DIR,chs=process.env.TRIPLE_C_PW_CHANNELS||"default",u=process.env.TRIPLE_C_PW_URL;"#,
r#"let done=false;const say=(ok,detail,nav)=>{if(done)return;done=true;"#, r#"let done=false;const say=(ok,detail,nav)=>{if(done)return;done=true;"#,
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_LAUNCH__"+JSON.stringify({ok,detail,nav:nav||null})+"\n");};"#, r#"process.stdout.write("\n__TRIPLE_C_BROWSER_LAUNCH__"+JSON.stringify({ok,detail,nav:nav||null})+"\n");};"#,
r#"const one=(e)=>String((e&&e.message)||e).split("\n").slice(0,8).join(" | ");"#, r#"const one=(e)=>String((e&&e.message)||e).split("\n").slice(0,8).join(" | ");"#,
r#"const t=setTimeout(()=>{say(false,"the browser did not finish starting within 90s");process.exit(0);},90000);"#, r#"const t=setTimeout(()=>{say(false,"the browser did not finish starting within 90s");process.exit(0);},90000);"#,
r#"(async()=>{let b=null;try{const {chromium}=require(d);b=await chromium.launch(ch?{channel:ch}:{});"#, r#"(async()=>{let b=null,cur="";try{const {chromium}=require(d);"#,
r#"let v="";try{v=b.version();}catch(e){}"#, r#"const list=chs.split(",").map(s=>s.trim()).filter(Boolean);"#,
r#"let nav={ok:true,cert:false,detail:""};"#, r#"let v="",nav={ok:true,cert:false,detail:""};"#,
r#"for(let i=0;i<list.length;i++){cur=list[i];const c=cur==="default"?undefined:cur;"#,
r#"b=await chromium.launch(Object.assign({chromiumSandbox:false},c?{channel:c}:{}));"#,
r#"try{v=b.version();}catch(e){}"#,
r#"if(i===list.length-1){"#,
r#"try{const p=await b.newPage();await p.goto(u,{timeout:20000});}"#, r#"try{const p=await b.newPage();await p.goto(u,{timeout:20000});}"#,
// A certificate failure is classified here, next to the message, because // A certificate failure is classified here, next to the message, because
// Chromium's wording is the only place the distinction exists. // Chromium's wording is the only place the distinction exists.
r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}"#, r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}}"#,
r#"await b.close();clearTimeout(t);say(true,v,nav);}"#, r#"await b.close();b=null;}"#,
r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}say(false,one(e));}"#, r#"clearTimeout(t);say(true,v,nav);}"#,
r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}"#,
r#"say(false,(cur&&cur!=="default"?"channel "+cur+": ":"")+one(e));}"#,
r#"process.exit(0);})();"#, r#"process.exit(0);})();"#,
); );
@@ -984,8 +1030,10 @@ mod tests {
// `@playwright/mcp` asks for the chrome channel specifically, so the UI // `@playwright/mcp` asks for the chrome channel specifically, so the UI
// must be able to say so. // must be able to say so.
assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp")); assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp"));
assert_eq!(BrowserTarget::Chrome.channel(), Some("chrome")); assert_eq!(BrowserTarget::Chrome.channels(), "chrome");
assert_eq!(BrowserTarget::Chromium.channel(), None); // Both of Chromium's consumers, or the check passes for a browser the
// viewer cannot open — see `channels`.
assert_eq!(BrowserTarget::Chromium.channels(), "default,chrome-for-testing");
// And a size, before the click, for both. // And a size, before the click, for both.
for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] { for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] {
assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t); assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t);
+23 -2
View File
@@ -164,6 +164,11 @@ pub struct ScheduledTask {
/// Only known for enabled one-shot tasks (their `at` time). Recurring cron /// Only known for enabled one-shot tasks (their `at` time). Recurring cron
/// expressions are not evaluated here. /// expressions are not evaluated here.
pub next_run: Option<String>, pub next_run: Option<String>,
/// Whether a run is in flight right now, from the runner's state file in
/// `~/.claude/scheduler/running/<id>.json` with its pid verified live.
pub running: bool,
/// When the in-flight run started, ISO 8601 (UTC). `None` unless `running`.
pub running_since: Option<String>,
} }
/// A completion notice written by `triple-c-task-runner` after a task ran. /// A completion notice written by `triple-c-task-runner` after a task ran.
@@ -614,13 +619,25 @@ const SCHEDULER_LIST_SCRIPT: &str = r#"exec 2>/dev/null
set -u set -u
TASKS="$HOME/.claude/scheduler/tasks" TASKS="$HOME/.claude/scheduler/tasks"
LOGS="$HOME/.claude/scheduler/logs" LOGS="$HOME/.claude/scheduler/logs"
RUNNING="$HOME/.claude/scheduler/running"
[ -d "$TASKS" ] || { echo '[]'; exit 0; } [ -d "$TASKS" ] || { echo '[]'; exit 0; }
for f in "$TASKS"/*.json; do for f in "$TASKS"/*.json; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
id=$(jq -r '.id // ""' "$f") || continue id=$(jq -r '.id // ""' "$f") || continue
[ -n "$id" ] || id=$(basename "$f" .json) [ -n "$id" ] || id=$(basename "$f" .json)
last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1) last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1)
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" '{ # Live-run state. The pid is checked, not trusted: a container stopped
# mid-run cannot fire the runner's cleanup trap, and a task stuck on
# "running" forever is a worse lie than showing nothing.
started=""
state="$RUNNING/$id.json"
if [ -f "$state" ]; then
pid=$(jq -r '.pid // empty' "$state")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
started=$(jq -r '.started_epoch // empty' "$state")
fi
fi
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" --arg started "$started" '{
id: (if (.id // "") == "" then $fallback_id else .id end), id: (if (.id // "") == "" then $fallback_id else .id end),
name: (.name // ""), name: (.name // ""),
prompt: (.prompt // ""), prompt: (.prompt // ""),
@@ -630,7 +647,8 @@ for f in "$TASKS"/*.json; do
enabled: (.enabled == true), enabled: (.enabled == true),
working_dir: (.working_dir // "/workspace"), working_dir: (.working_dir // "/workspace"),
created_at: (.created_at // null), created_at: (.created_at // null),
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end) last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end),
running_since_epoch: (if $started == "" then null else ($started | tonumber) end)
}' "$f" }' "$f"
done | jq -s 'sort_by(.name, .id)' done | jq -s 'sort_by(.name, .id)'
"#; "#;
@@ -673,6 +691,7 @@ struct RawScheduledTask {
working_dir: String, working_dir: String,
created_at: Option<String>, created_at: Option<String>,
last_run_epoch: Option<i64>, last_run_epoch: Option<i64>,
running_since_epoch: Option<i64>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -723,6 +742,8 @@ pub async fn list_scheduled_tasks(
created_at: t.created_at, created_at: t.created_at,
last_run: t.last_run_epoch.map(epoch_to_iso), last_run: t.last_run_epoch.map(epoch_to_iso),
next_run, next_run,
running: t.running_since_epoch.is_some(),
running_since: t.running_since_epoch.map(epoch_to_iso),
} }
}) })
.collect()) .collect())
@@ -73,6 +73,25 @@ use crate::AppState;
/// Report how far behind the current base image a project's container is, and /// Report how far behind the current base image a project's container is, and
/// what migrating it would actually carry across. /// what migrating it would actually carry across.
/// ///
/// Choose the recorded lineage from the two places it can be written, most
/// authoritative first: the live container's label, then the snapshot image's.
///
/// **An empty label is absence, not an answer.** `create_container` always
/// writes `triple-c.base-image-id`, even when the value is unknown — that is
/// deliberate, because Docker merges an image's labels into a container's and
/// an inherited value would otherwise ride a snapshot forever. The consequence
/// is that `Some("")` is the *common* reading from a container whose lineage
/// was never established, so treating it as an answer silently skips the
/// snapshot, which may well have recorded a real one.
fn pick_recorded_lineage(
from_container: Option<String>,
from_snapshot: Option<String>,
) -> Option<String> {
from_container
.filter(|v| !v.is_empty())
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
}
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to /// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
/// be called on demand, not polled. /// be called on demand, not polled.
#[tauri::command] #[tauri::command]
@@ -98,20 +117,24 @@ pub async fn get_container_staleness(
// Lineage, most authoritative source first: the live container's label, // Lineage, most authoritative source first: the live container's label,
// then the snapshot image's. Both are written by `create_container` and // then the snapshot image's. Both are written by `create_container` and
// propagated onto the snapshot by `docker commit`. // propagated onto the snapshot by `docker commit`.
// Each source is filtered for emptiness *before* it is allowed to satisfy
// the lookup. `create_container` always writes this label, even when the
// value is unknown — deliberately, so an inherited image label cannot ride
// a snapshot forever — which means the container's copy is very often
// `Some("")`. Filtering only the final result let that empty string count
// as an answer and skip the snapshot entirely, so a snapshot that *did*
// record a lineage was never consulted and the project reported "unknown"
// with the information sitting one lookup away.
let container_id = docker::find_existing_container(&project).await.unwrap_or(None); let container_id = docker::find_existing_container(&project).await.unwrap_or(None);
let recorded = match &container_id { let from_container = match &container_id {
Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await, Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await,
None => None, None => None,
} };
.or_else(|| None); let from_snapshot = mig::image_labels(&snapshot_image)
let recorded = match recorded {
Some(v) => Some(v),
None => mig::image_labels(&snapshot_image)
.await .await
.get(mig::LABEL_BASE_IMAGE_ID) .get(mig::LABEL_BASE_IMAGE_ID)
.cloned(), .cloned();
} let recorded = pick_recorded_lineage(from_container, from_snapshot);
.filter(|v| !v.is_empty());
out.base_image_id = recorded.clone(); out.base_image_id = recorded.clone();
out.known = recorded.is_some(); out.known = recorded.is_some();
@@ -1671,6 +1694,32 @@ fn summarize(
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn an_empty_lineage_label_is_absence_and_falls_through_to_the_snapshot() {
let some = |s: &str| Some(s.to_string());
// The regression: the container always carries the label, so an
// unknown lineage reads as `Some("")`. Letting that satisfy the lookup
// skipped a snapshot that had recorded the real thing.
assert_eq!(
pick_recorded_lineage(some(""), some("sha256:base")),
some("sha256:base")
);
// Ordinary precedence still holds: the container wins when it has one.
assert_eq!(
pick_recorded_lineage(some("sha256:container"), some("sha256:snapshot")),
some("sha256:container")
);
assert_eq!(pick_recorded_lineage(None, some("sha256:snap")), some("sha256:snap"));
// Genuinely unknown stays unknown — "probe instead", never a lineage
// invented to make the comparison succeed.
assert_eq!(pick_recorded_lineage(None, None), None);
assert_eq!(pick_recorded_lineage(some(""), some("")), None);
assert_eq!(pick_recorded_lineage(some(""), None), None);
}
#[test] #[test]
fn byte_sizes_read_the_way_a_disk_warning_should() { fn byte_sizes_read_the_way_a_disk_warning_should() {
assert_eq!(human_bytes(512), "512 B"); assert_eq!(human_bytes(512), "512 B");
+4 -3
View File
@@ -18,11 +18,12 @@ This container supports scheduled tasks via `triple-c-scheduler`. You can set up
### Commands ### Commands
- `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` Add a recurring task - `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` Add a recurring task
- `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` Add a one-time task - `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` Add a one-time task
- `triple-c-scheduler list` List all scheduled tasks - `triple-c-scheduler list` List all scheduled tasks, with a running/idle status column
- `triple-c-scheduler remove --id ID` Remove a task - `triple-c-scheduler remove --id ID` Remove a task
- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` Toggle tasks - `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` Toggle tasks
- `triple-c-scheduler status [--id ID] [--watch]` Show what is running right now, and for how long
- `triple-c-scheduler logs [--id ID] [--tail N]` View execution logs - `triple-c-scheduler logs [--id ID] [--tail N]` View execution logs
- `triple-c-scheduler run --id ID` Manually trigger a task immediately - `triple-c-scheduler run --id ID` Manually trigger a task immediately (streams its log)
- `triple-c-scheduler notifications [--clear]` View or clear completion notifications - `triple-c-scheduler notifications [--clear]` View or clear completion notifications
### Cron format ### Cron format
@@ -36,7 +37,7 @@ Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically re
Use `--working-dir /workspace/project` to set where the task runs (default: /workspace). Use `--working-dir /workspace/project` to set where the task runs (default: /workspace).
### Checking results ### Checking results
After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`. While a task is running, `triple-c-scheduler status` reports it with elapsed time a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.
### Timezone ### Timezone
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#; Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import AutomationTab from "./AutomationTab";
import type { Project, ScheduledTask } from "../../../lib/types";
const listScheduledTasks = vi.fn(async () => tasks);
const getSchedulerNotifications = vi.fn(async () => []);
const runScheduledTaskNow = vi.fn(async () => "started");
const pushToast = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({
listScheduledTasks: () => listScheduledTasks(),
getSchedulerNotifications: () => getSchedulerNotifications(),
runScheduledTaskNow: (p: string, t: string) => runScheduledTaskNow(p, t),
clearSchedulerNotifications: vi.fn(async () => {}),
getScheduledTaskLog: vi.fn(async () => ""),
removeScheduledTask: vi.fn(async () => {}),
setScheduledTaskEnabled: vi.fn(async () => {}),
}));
vi.mock("../../../store/appState", () => ({
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
}));
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
const baseTask: ScheduledTask = {
id: "a1b2c3d4",
name: "nightly",
prompt: "Run the suite",
schedule: "0 3 * * *",
task_type: "recurring",
at: null,
enabled: true,
working_dir: "/workspace",
created_at: null,
last_run: null,
next_run: null,
running: false,
running_since: null,
};
let tasks: ScheduledTask[] = [];
async function renderTab() {
render(<AutomationTab project={project} />);
await act(async () => {
await Promise.resolve();
});
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
tasks = [baseTask];
listScheduledTasks.mockClear();
runScheduledTaskNow.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
describe("AutomationTab run state", () => {
it("offers Run now for an idle task and says nothing about running", async () => {
await renderTab();
expect(screen.getByRole("button", { name: "Run now" })).toBeEnabled();
expect(screen.queryByText(/Running/)).toBeNull();
});
it("shows a running task as running, with elapsed time, and blocks a second trigger", async () => {
const startedSecondsAgo = new Date(Date.now() - 90_000).toISOString();
tasks = [{ ...baseTask, running: true, running_since: startedSecondsAgo }];
await renderTab();
// The whole point: a detached run is visible rather than silent.
expect(screen.getByText(/Running for 1m/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
});
it("keeps polling after a trigger, so a run that has not registered yet still appears", async () => {
await renderTab();
const callsAfterLoad = listScheduledTasks.mock.calls.length;
// The runner needs a moment to write its state file; until then the task
// still reads as idle, which is exactly the window that used to look dead.
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Run now" }));
await Promise.resolve();
});
expect(runScheduledTaskNow).toHaveBeenCalledWith("p1", "a1b2c3d4");
tasks = [{ ...baseTask, running: true, running_since: new Date().toISOString() }];
await act(async () => {
vi.advanceTimersByTime(2000);
await Promise.resolve();
});
expect(listScheduledTasks.mock.calls.length).toBeGreaterThan(callsAfterLoad);
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
});
it("stops polling once nothing is running", async () => {
await renderTab();
// No trigger, nothing running: the interval must not be armed at all.
const before = listScheduledTasks.mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(30_000);
await Promise.resolve();
});
expect(listScheduledTasks.mock.calls.length).toBe(before);
});
});
@@ -15,7 +15,7 @@ import Toggle from "../../ui/Toggle";
import Modal from "../../ui/Modal"; import Modal from "../../ui/Modal";
import StatusIndicator from "../../ui/StatusIndicator"; import StatusIndicator from "../../ui/StatusIndicator";
import TaskEditorModal from "./TaskEditorModal"; import TaskEditorModal from "./TaskEditorModal";
import { formatAge } from "./format"; import { formatAge, formatRunningFor } from "./format";
interface Props { interface Props {
project: Project; project: Project;
@@ -59,6 +59,22 @@ export default function AutomationTab({ project }: Props) {
useEffect(load, [load]); useEffect(load, [load]);
// A task in flight is the one state this view cannot sit still for: runs are
// detached, so without polling "Run now" looks like it did nothing until the
// user reaches for Refresh. Polling stops as soon as nothing is running.
//
// `justTriggered` covers the gap between firing a run and the runner writing
// its state file — a second or two in which the task still reads as idle, and
// where giving up on polling would reproduce the exact silence this fixes.
const anyTaskRunning = tasks.some((t) => t.running);
const [justTriggered, setJustTriggered] = useState(0);
useEffect(() => {
if (!running) return;
if (!anyTaskRunning && Date.now() - justTriggered > 20_000) return;
const timer = setInterval(load, anyTaskRunning ? 5000 : 1500);
return () => clearInterval(timer);
}, [running, anyTaskRunning, justTriggered, load]);
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => { const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
setBusyTaskId(taskId); setBusyTaskId(taskId);
try { try {
@@ -185,6 +201,12 @@ export default function AutomationTab({ project }: Props) {
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]"> <span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
{task.task_type} {task.task_type}
</span> </span>
{task.running && (
<StatusIndicator
tone="busy"
label={`Running ${formatRunningFor(task.running_since) ?? ""}`.trim()}
/>
)}
</div> </div>
<div className="text-xs text-[var(--text-secondary)] font-mono truncate"> <div className="text-xs text-[var(--text-secondary)] font-mono truncate">
{task.at ?? task.schedule} {task.at ?? task.schedule}
@@ -202,14 +224,15 @@ export default function AutomationTab({ project }: Props) {
} }
/> />
<Button <Button
disabled={busyTaskId === task.id} disabled={busyTaskId === task.id || task.running}
onClick={() => onClick={() =>
withTask(task.id, "Run now", () => withTask(task.id, "Run now", async () => {
runScheduledTaskNow(project.id, task.id), await runScheduledTaskNow(project.id, task.id);
) setJustTriggered(Date.now());
})
} }
> >
Run now {task.running ? "Running…" : "Run now"}
</Button> </Button>
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}> <Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
Edit Edit
@@ -127,6 +127,46 @@ describe("ContainerMigrationBanner", () => {
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
it("speaks up when an unlabelled container could not be probed at all", () => {
// The probe is the only signal a container with no lineage label has. If
// it fails and the banner stays silent, that is indistinguishable from
// "up to date" — the exact reading that let an out-of-date project go
// unnoticed indefinitely.
renderBanner(
migration({
staleness: {
...FRESH,
known: false,
stale: false,
probe_error: "output exceeded the inspection limit",
},
probeSettled: false,
}),
);
expect(
screen.getByText(/Container base could not be checked/i),
).toBeInTheDocument();
expect(
screen.getByText(/output exceeded the inspection limit/i),
).toBeInTheDocument();
// And it must not pose as a finding about the container itself.
expect(
screen.queryByText(/Container is missing things/i),
).not.toBeInTheDocument();
});
it("stays quiet when a labelled container's probe fails but its lineage is current", () => {
// `known` means the version comparison already answered the question, so
// a failed probe is not grounds to raise anything.
const { container } = renderBanner(
migration({
staleness: { ...FRESH, probe_error: "could not exec in the container" },
probeSettled: false,
}),
);
expect(container).toBeEmptyDOMElement();
});
it("disables the action and explains why while the container is running", () => { it("disables the action and explains why while the container is running", () => {
renderBanner(migration({ staleness: STALE }), false); renderBanner(migration({ staleness: STALE }), false);
expect( expect(
@@ -131,7 +131,15 @@ export default function ContainerMigrationBanner({
const probeFoundGaps = const probeFoundGaps =
!staleness.known && !staleness.known &&
(staleness.missing_features.length > 0 || staleness.missing_paths.length > 0); (staleness.missing_features.length > 0 || staleness.missing_paths.length > 0);
if (!staleness.stale && !probeFoundGaps) return null;
// The probe is the *only* signal a container with no lineage label has, so
// when it fails there is nothing left to be quiet about. Staying silent here
// is indistinguishable from "everything is fine" — and it is the likeliest
// outcome for the oldest, largest projects, whose manifests are the ones apt
// to exceed the inspection limit. Say that the check did not run instead.
const probeUnavailable = !staleness.known && !!staleness.probe_error;
if (!staleness.stale && !probeFoundGaps && !probeUnavailable) return null;
const snapshot = formatSnapshotDate(staleness.snapshot_created_at); const snapshot = formatSnapshotDate(staleness.snapshot_created_at);
const features = joinFeatures(staleness.missing_features); const features = joinFeatures(staleness.missing_features);
@@ -139,15 +147,24 @@ export default function ContainerMigrationBanner({
return ( return (
<section <section
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`} className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
aria-label="Container base is out of date" aria-label={
probeUnavailable
? "Container base could not be checked"
: "Container base is out of date"
}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<StatusIndicator <StatusIndicator
tone="error" // A check that could not run is not a finding: it gets the
// "unresolved" tone rather than the one that says something is
// wrong with the container.
tone={probeUnavailable ? "unknown" : "error"}
label={ label={
staleness.known staleness.known
? "Container base is out of date" ? "Container base is out of date"
: probeUnavailable
? "Container base could not be checked"
: "Container is missing things the current base ships" : "Container is missing things the current base ships"
} }
className="text-[13px] font-semibold" className="text-[13px] font-semibold"
@@ -158,6 +175,8 @@ export default function ContainerMigrationBanner({
? snapshot ? snapshot
? `Running on a saved image from ${snapshot}.` ? `Running on a saved image from ${snapshot}.`
: "Running on a saved image older than the current base." : "Running on a saved image older than the current base."
: probeUnavailable
? "This container predates base-image tracking, so probing it is the only way to tell whether it is behind — and that did not complete."
: "This container predates base-image tracking, so it was probed directly."} : "This container predates base-image tracking, so it was probed directly."}
</p> </p>
@@ -60,6 +60,8 @@ const existingTask: ScheduledTask = {
created_at: null, created_at: null,
last_run: null, last_run: null,
next_run: null, next_run: null,
running: false,
running_since: null,
}; };
async function renderEditor(task: ScheduledTask | null = null, project = baseProject) { async function renderEditor(task: ScheduledTask | null = null, project = baseProject) {
@@ -26,6 +26,21 @@ export function formatElapsed(ms: number): string {
return `${days}d ago`; return `${days}d ago`;
} }
/** "for 42s" / "for 4m" / "for 1h 12m" elapsed phrasing for a run in flight.
* Seconds are kept below a minute because the first thing anyone wants from a
* freshly triggered run is evidence that it started at all. */
export function formatRunningFor(iso: string | null | undefined): string | null {
if (!iso) return null;
const started = Date.parse(iso);
if (Number.isNaN(started)) return null;
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
if (seconds < 60) return `for ${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `for ${minutes}m`;
const hours = Math.floor(minutes / 60);
return `for ${hours}h ${minutes % 60}m`;
}
/** Uptime phrasing for a known start timestamp. */ /** Uptime phrasing for a known start timestamp. */
export function formatUptime(startedAtMs: number | undefined): string | null { export function formatUptime(startedAtMs: number | undefined): string | null {
if (startedAtMs === undefined) return null; if (startedAtMs === undefined) return null;
+4
View File
@@ -388,6 +388,10 @@ export interface ScheduledTask {
last_run: string | null; last_run: string | null;
/** Known only for enabled one-shot tasks; cron is not evaluated. */ /** Known only for enabled one-shot tasks; cron is not evaluated. */
next_run: string | null; next_run: string | null;
/** A run is in flight right now (the runner's pid was verified live). */
running: boolean;
/** When that run started. Null unless `running`. */
running_since: string | null;
} }
/** Mirrors Rust `ScheduleKind` which of the scheduler's two `add` flags to /** Mirrors Rust `ScheduleKind` which of the scheduler's two `add` flags to
+7
View File
@@ -318,6 +318,13 @@ COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
RUN chmod +x /usr/local/bin/triple-c-scheduler RUN chmod +x /usr/local/bin/triple-c-scheduler
# Lives in /usr/local/bin rather than under /home/claude on purpose: the home
# directory is the mount point of the project's home volume, so an image copy
# of it is masked after the project's first start and can never be updated
# again. /usr/local/bin rides the snapshot and is replaced on migration, which
# is what lets a fix to this script reach an existing project at all.
COPY triple-c-playwright-heal /usr/local/bin/triple-c-playwright-heal
RUN chmod +x /usr/local/bin/triple-c-playwright-heal
COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner
RUN chmod +x /usr/local/bin/triple-c-task-runner RUN chmod +x /usr/local/bin/triple-c-task-runner
+36 -1
View File
@@ -425,6 +425,32 @@ if [ -x /usr/local/bin/triple-c-open ]; then
export BROWSER=/usr/local/bin/triple-c-open export BROWSER=/usr/local/bin/triple-c-open
fi fi
# ── Playwright browser config ───────────────────────────────────────────────
# Seed ~/.playwright/cli.config.json on every start.
#
# Without it `playwright-cli` resolves to channel `chrome` — system Google
# Chrome — with the Chromium sandbox ON, and these containers do not permit
# unprivileged user namespaces, so the browser aborts with "Failed to move to
# new namespace ... Operation not permitted". On a base image that no longer
# ships Google Chrome the same default fails the other way, with "Chromium
# distribution 'chrome' is not found". One cause, two error messages, and
# neither of them looks like a configuration problem.
#
# Seeded here rather than baked into the image because ~/.playwright is inside
# the home volume: an image copy would reach new projects only, and every
# existing project would stay broken forever. Written on every start from a
# source outside the volume, the way CLAUDE_INSTRUCTIONS and the Mission
# Control skills already are.
#
# --seed-config-only is the cheap path: no npm install, no browser download, no
# apt, no verify launch, nothing over the network. It writes one small file if
# it is absent and returns. Measured at ~2 ms. The heavier repairs stay
# on-demand — run `triple-c-playwright-heal` with no arguments for those.
if [ -x /usr/local/bin/triple-c-playwright-heal ]; then
/usr/local/bin/triple-c-playwright-heal --seed-config-only --quiet || \
echo "entrypoint: warning — playwright config seeding failed (browser view may not launch)"
fi
# ── Scheduler setup ───────────────────────────────────────────────────────── # ── Scheduler setup ─────────────────────────────────────────────────────────
SCHEDULER_DIR="/home/claude/.claude/scheduler" SCHEDULER_DIR="/home/claude/.claude/scheduler"
mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications" mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications"
@@ -434,17 +460,26 @@ chown -R claude:claude "$SCHEDULER_DIR"
cron cron
# Save environment variables for cron jobs (cron runs with a minimal env) # Save environment variables for cron jobs (cron runs with a minimal env)
#
# HOME is deliberately NOT captured here. This entrypoint runs as root, so the
# snapshot would record HOME=/root — and the task runner sources this file with
# `set -a`, which would overwrite the HOME cron gives the job. Claude Code then
# looks for its OAuth credential at /root/.claude/.credentials.json instead of
# /home/claude/.claude/.credentials.json and every scheduled task dies with
# "Not logged in · Please run /login". Cron still needs a HOME, so it is written
# explicitly below with the value the `claude` user actually has.
ENV_FILE="$SCHEDULER_DIR/.env" ENV_FILE="$SCHEDULER_DIR/.env"
: > "$ENV_FILE" : > "$ENV_FILE"
env | while IFS='=' read -r key value; do env | while IFS='=' read -r key value; do
case "$key" in case "$key" in
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE) ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE)
# Escape single quotes in value and write as KEY='VALUE' # Escape single quotes in value and write as KEY='VALUE'
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g") escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE" printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
;; ;;
esac esac
done done
printf "HOME='/home/claude'\n" >> "$ENV_FILE"
chown claude:claude "$ENV_FILE" chown claude:claude "$ENV_FILE"
chmod 600 "$ENV_FILE" chmod 600 "$ENV_FILE"
+272
View File
@@ -0,0 +1,272 @@
#!/bin/bash
# triple-c-playwright-heal — make Playwright usable in a Triple-C container.
#
# Idempotent: safe to run on every start and safe to re-run after a partial
# failure. Each step checks for its own result first, so a healthy container is
# a fast no-op that still prints why it is healthy. The last step is the only
# one that means anything: it launches a browser for real.
#
# The things that go wrong, in the order they bite:
#
# 1. @playwright/cli missing — including the case where it was installed and
# then silently removed again. `npm install --no-save <pkg>` in /workspace,
# which has no package.json, prunes packages npm considers extraneous, so
# installing @playwright/cli and then installing playwright wipes the
# first one and leaves an empty node_modules/@playwright/. That directory
# reads as "installed" to a naive check, which is why this script tests
# the package *entry point*.
#
# 2. Bundled chromium missing or the wrong revision. Browsers live in the
# home volume and outlive any single @playwright/cli install, so a stale
# chromium-<old> is routinely present while the installed playwright-core
# wants a newer one. Must be installed AS claude: run as root it lands in
# /root/.cache/ms-playwright where the agent cannot see it.
#
# 3. No cli.config.json — the one that breaks an otherwise clean install.
# With no config, playwright-cli resolves to channel `chrome` (system
# Google Chrome) with the sandbox ON. These containers forbid unprivileged
# user namespaces, so Chrome aborts with "Failed to move to new namespace
# ... Operation not permitted". On newer base images Chrome is not present
# at all and it fails with "Chromium distribution 'chrome' is not found".
# Same root cause both ways: the default channel is wrong here.
#
# 4. The storage-state file the config points at is missing. Playwright
# treats an unreadable storageState as a hard error on every launch, not
# as "no saved state", so the file has to exist from the very first run.
#
# 5. xvfb or socat missing (older base images only). Headless Playwright
# needs neither; the `playwright-cli show` dashboard needs xvfb, and the
# browser-view pane needs socat — without it the pane reports
# "127.0.0.1 sent an invalid response" while the container side is fine.
#
# Usage: triple-c-playwright-heal [--seed-config-only] [--force-config] [--quiet]
# --seed-config-only only ensure the config and its storage-state file
# exist. No npm install, no browser download, no apt, no
# verify launch. Cheap and offline — this is the mode
# entrypoint.sh runs on every container start.
# --force-config overwrite an existing config instead of keeping it
# --quiet print only problems and repairs, not healthy no-ops
set -u
TARGET_USER=claude
TARGET_HOME=/home/claude
PW_DIR=/workspace
CONFIG_DIR="$TARGET_HOME/.playwright"
CONFIG_FILE="$CONFIG_DIR/cli.config.json"
STATE_FILE="$CONFIG_DIR/storage-state.json"
CLI_ENTRY="$PW_DIR/node_modules/@playwright/cli/playwright-cli.js"
FORCE_CONFIG=0
QUIET=0
SEED_ONLY=0
for arg in "$@"; do
case "$arg" in
--force-config) FORCE_CONFIG=1 ;;
--quiet) QUIET=1 ;;
--seed-config-only) SEED_ONLY=1 ;;
*) echo "playwright-heal: unknown option: $arg" >&2; exit 2 ;;
esac
done
changed=0
failed=0
say() { [ "$QUIET" = 1 ] || echo "playwright-heal: $*"; }
warn() { echo "playwright-heal: $*" >&2; }
did() { changed=1; echo "playwright-heal: $*"; }
# Run as claude whether we were invoked as root (docker exec / entrypoint) or
# as claude (terminal session). Nothing user-visible may end up root-owned.
as_claude() {
if [ "$(id -u)" = 0 ]; then
su "$TARGET_USER" -s /bin/sh -c "$1"
else
sh -c "$1"
fi
}
# ── 1. @playwright/cli ───────────────────────────────────────────────────────
if [ "$SEED_ONLY" = 1 ]; then
:
elif [ -f "$CLI_ENTRY" ]; then
say "@playwright/cli present"
else
# An empty leftover @playwright/ can make npm consider the tree settled.
if [ -d "$PW_DIR/node_modules/@playwright" ]; then
say "clearing partial @playwright install"
rm -rf "$PW_DIR/node_modules/@playwright"
fi
say "installing @playwright/cli..."
if as_claude "cd $PW_DIR && npm install --no-save --no-audit --no-fund @playwright/cli" >/tmp/pw-heal-npm.log 2>&1; then
did "installed @playwright/cli"
else
warn "npm install failed; see /tmp/pw-heal-npm.log"
failed=1
fi
fi
# ── 2. bundled chromium ──────────────────────────────────────────────────────
# Ask Playwright where *this* version's chromium belongs rather than globbing
# chromium-*, which would call a stale revision "present" and then fail at
# launch with 'Browser "chrome-for-testing" is not installed'. --dry-run prints
# the install location for the installed version and downloads nothing.
if [ "$SEED_ONLY" = 1 ]; then
:
else
chromium_dir=""
if [ -f "$PW_DIR/node_modules/playwright-core/cli.js" ]; then
chromium_dir=$(as_claude "cd $PW_DIR && node node_modules/playwright-core/cli.js install --dry-run chromium 2>/dev/null" \
| awk '/Install location:/ { print $3; exit }')
fi
if [ -n "$chromium_dir" ] && [ -d "$chromium_dir" ]; then
say "chromium present ($(basename "$chromium_dir"))"
elif [ -f "$PW_DIR/node_modules/playwright-core/cli.js" ]; then
say "downloading chromium (~300 MB)..."
if as_claude "cd $PW_DIR && node node_modules/playwright-core/cli.js install chromium" >/tmp/pw-heal-browser.log 2>&1; then
did "installed chromium"
else
warn "chromium install failed; see /tmp/pw-heal-browser.log"
failed=1
fi
else
warn "playwright-core missing, cannot install chromium"
failed=1
fi
fi
# ── 3. cli.config.json ───────────────────────────────────────────────────────
# The *global* config, not a project-level .playwright/, because the project
# one resolves relative to the current working directory and silently stops
# applying the moment you cd elsewhere.
#
# `chrome-for-testing` is the only recognised chromium alias — "chromium" is
# not one and falls back to system Chrome. chromiumSandbox:false is what
# actually appends --no-sandbox.
write_config() {
mkdir -p "$CONFIG_DIR" || return 1
cat > "$CONFIG_FILE" <<EOF
{
"browser": {
"browserName": "chromium",
"launchOptions": {
"channel": "chrome-for-testing",
"chromiumSandbox": false,
"args": ["--no-sandbox", "--disable-dev-shm-usage"]
},
"contextOptions": {
"storageState": "$STATE_FILE"
}
}
}
EOF
chown -R "$TARGET_USER:$TARGET_USER" "$CONFIG_DIR" 2>/dev/null || true
}
# storageState is a *load* path, and Playwright reads it at context creation.
# A path that does not exist is not treated as "no saved state" — it is a hard
# error, "Error reading storage state from …", on every single launch. So the
# file has to exist before the config that names it can be used at all, and it
# has to be recreated if anything deletes it. An empty state is valid and
# behaves exactly like no state.
#
# The path is read back out of the config rather than assumed, so a
# hand-edited config pointing somewhere else still gets its file created
# instead of being silently broken by ours.
ensure_state_file() {
[ -f "$CONFIG_FILE" ] || return 0
state_path=$(grep -o '"storageState"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_FILE" 2>/dev/null \
| sed 's/.*"\([^"]*\)"[[:space:]]*$/\1/')
[ -n "$state_path" ] || return 0
[ -f "$state_path" ] && return 0
mkdir -p "$(dirname "$state_path")" 2>/dev/null
printf '{\n "cookies": [],\n "origins": []\n}\n' > "$state_path" || return 1
chown "$TARGET_USER:$TARGET_USER" "$state_path" 2>/dev/null || true
did "created empty $state_path (storageState needs it to exist)"
}
if [ ! -f "$CONFIG_FILE" ]; then
if write_config; then did "wrote $CONFIG_FILE"; else warn "could not write $CONFIG_FILE"; failed=1; fi
elif [ "$FORCE_CONFIG" = 1 ]; then
if write_config; then did "overwrote $CONFIG_FILE (--force-config)"; else warn "could not write $CONFIG_FILE"; failed=1; fi
elif grep -q '"chromiumSandbox"[[:space:]]*:[[:space:]]*false' "$CONFIG_FILE" 2>/dev/null; then
say "config present and disables the sandbox"
else
# Present but hand-edited into a state that will not launch. Do not clobber
# deliberate config silently; say what is wrong and how to replace it.
warn "config at $CONFIG_FILE does not set chromiumSandbox:false — the browser will likely fail to launch. Re-run with --force-config to replace it."
fi
# Unconditional: the config may name a storageState this run did not write —
# one seeded by an older version of this script, or edited by hand — and a
# missing file there breaks every launch.
ensure_state_file || { warn "could not create the storage-state file"; failed=1; }
if [ "$SEED_ONLY" = 1 ]; then
[ "$failed" = 1 ] && exit 1
exit 0
fi
# ── 4. xvfb (headed dashboard only) ──────────────────────────────────────────
# Current base images get this from `playwright install-deps` (its `tools`
# group); older ones predate that layer. Headless never needs it, so a missing
# xvfb is a note, not a failure.
if command -v Xvfb >/dev/null 2>&1; then
say "xvfb present"
elif [ "$(id -u)" = 0 ]; then
say "installing xvfb (needed only for the headed dashboard)..."
if (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq xvfb) >/tmp/pw-heal-xvfb.log 2>&1; then
did "installed xvfb"
else
warn "xvfb install failed (headless still works); see /tmp/pw-heal-xvfb.log"
fi
else
say "xvfb missing and not running as root — skipping (headless still works)"
fi
# ── 4b. socat (the browser-view pane's tunnel) ───────────────────────────────
# Not Playwright's, but the same class of failure and it presents as a
# Playwright problem: the pane's host-side proxy reaches the dashboard by
# running `socat` *inside* the container over a Docker exec. On a container old
# enough to predate socat in the base image, that exec produces something that
# is not an HTTP response, and the webview reports "127.0.0.1 sent an invalid
# response" — with the container side working perfectly. A project keeps the
# base image it was first built from until it is migrated, so this is the
# normal case on an older project, not an exotic one.
if command -v socat >/dev/null 2>&1; then
say "socat present"
elif [ "$(id -u)" = 0 ]; then
say "installing socat (needed by the browser-view pane)..."
if (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq socat) >/tmp/pw-heal-socat.log 2>&1; then
did "installed socat"
else
warn "socat install failed; the browser-view pane will report an invalid response. See /tmp/pw-heal-socat.log"
failed=1
fi
else
warn "socat missing and not running as root — the browser-view pane will report an invalid response"
fi
# ── 5. verify by actually launching ──────────────────────────────────────────
# Every step above can report success while the browser still refuses to
# start — that is precisely how this broke. A dedicated session name keeps
# this clear of whatever the agent already has open.
if [ -f "$CLI_ENTRY" ]; then
verify_out=$(as_claude "cd /tmp && timeout 90 node $CLI_ENTRY -s=heal-verify open 'data:text/html,<h1>ok</h1>' 2>&1")
if printf '%s' "$verify_out" | grep -q 'opened with pid'; then
say "verified: browser launches"
as_claude "cd /tmp && timeout 30 node $CLI_ENTRY -s=heal-verify close" >/dev/null 2>&1
else
warn "browser still fails to launch:"
printf '%s\n' "$verify_out" | grep -m4 -E 'namespace|Check failed|is not installed|is not found|missing dependencies|Error' >&2
failed=1
fi
else
warn "@playwright/cli not installed — nothing to verify"
failed=1
fi
[ "$failed" = 1 ] && exit 1
[ "$changed" = 1 ] && say "done — repairs applied" || say "done — nothing to repair"
exit 0
+180 -6
View File
@@ -8,17 +8,59 @@ SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks" TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs" LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications" NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
RUNNING_DIR="${SCHEDULER_DIR}/running"
# ── Helpers ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────
ensure_dirs() { ensure_dirs() {
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR" mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR" "$RUNNING_DIR"
} }
generate_id() { generate_id() {
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n' head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
} }
# Live run state for a task: prints "pid<TAB>started_epoch<TAB>log" and returns
# 0 when the task is genuinely running, returns 1 otherwise.
#
# triple-c-task-runner writes the file and removes it from an EXIT trap, but a
# trap cannot fire for SIGKILL or a container stop mid-run. So the pid is
# checked rather than believed, and a state file whose process is gone is
# cleared here — otherwise one hard stop leaves a task reading as "running"
# forever, which is worse than no indicator at all.
run_state() {
local id="$1"
local state_file="${RUNNING_DIR}/${id}.json"
[ -f "$state_file" ] || return 1
local pid
pid=$(jq -r '.pid // empty' "$state_file" 2>/dev/null)
if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
rm -f "$state_file"
return 1
fi
printf '%s\t%s\t%s\n' \
"$pid" \
"$(jq -r '.started_epoch // 0' "$state_file")" \
"$(jq -r '.log // ""' "$state_file")"
}
# Compact elapsed time since an epoch, e.g. "8s", "4m12s", "1h07m".
elapsed_since() {
local start="$1" now delta
now=$(date +%s)
delta=$(( now - start ))
[ "$delta" -lt 0 ] && delta=0
if [ "$delta" -ge 3600 ]; then
printf '%dh%02dm' $(( delta / 3600 )) $(( (delta % 3600) / 60 ))
elif [ "$delta" -ge 60 ]; then
printf '%dm%02ds' $(( delta / 60 )) $(( delta % 60 ))
else
printf '%ds' "$delta"
fi
}
# Reject a malformed cron expression at the point of entry. # Reject a malformed cron expression at the point of entry.
# #
# Without this an invalid schedule is written to a task file, and the next # Without this an invalid schedule is written to a task file, and the next
@@ -85,8 +127,9 @@ Commands:
enable Enable a disabled task enable Enable a disabled task
disable Disable a task disable Disable a task
list List all tasks list List all tasks
status Show which tasks are running right now
logs Show execution logs logs Show execution logs
run Manually trigger a task now run Manually trigger a task now (streams its log)
notifications Show or clear completion notifications notifications Show or clear completion notifications
Add options: Add options:
@@ -99,6 +142,10 @@ Add options:
Remove/Enable/Disable/Run options: Remove/Enable/Disable/Run options:
--id ID Task ID (required) --id ID Task ID (required)
Status options:
--id ID Show one task, including its last result when idle
--watch, -w Refresh every 5s until the run finishes
Logs options: Logs options:
--id ID Show logs for a specific task (optional) --id ID Show logs for a specific task (optional)
--tail N Show last N lines (default: 50) --tail N Show last N lines (default: 50)
@@ -313,8 +360,8 @@ cmd_disable() {
cmd_list() { cmd_list() {
local found=false local found=false
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "PROMPT" printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "STATUS" "PROMPT"
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "──────────────────────────────" printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "────────────" "──────────────────────────────"
for task_file in "$TASKS_DIR"/*.json; do for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue [ -f "$task_file" ] || continue
@@ -333,12 +380,21 @@ cmd_list() {
display_schedule="at $at" display_schedule="at $at"
fi fi
local status state started
if state=$(run_state "$id"); then
started=$(printf '%s' "$state" | cut -f2)
status="running $(elapsed_since "$started")"
else
status="idle"
fi
# Truncate long fields for display # Truncate long fields for display
[ ${#name} -gt 20 ] && name="${name:0:17}..." [ ${#name} -gt 20 ] && name="${name:0:17}..."
[ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..." [ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
[ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..." [ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "$id" "$name" "$type" "$enabled" "$display_schedule" "$prompt" printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" \
"$id" "$name" "$type" "$enabled" "$display_schedule" "$status" "$prompt"
done done
if [ "$found" = "false" ]; then if [ "$found" = "false" ]; then
@@ -346,6 +402,78 @@ cmd_list() {
fi fi
} }
# Is anything running, and how far along is it?
#
# This is the command for the question "did my `run` do anything, or has it
# stalled?" — `logs` alone cannot answer it, because a log that stops growing
# looks identical whether Claude is thinking or the run is dead.
cmd_status() {
local id="" watch=false
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
--watch|-w) watch=true; shift ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
while true; do
local any=false
for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue
local tid
tid=$(jq -r '.id' "$task_file")
[ -z "$id" ] || [ "$tid" = "$id" ] || continue
local name state
name=$(jq -r '.name' "$task_file")
if state=$(run_state "$tid"); then
any=true
local pid started log
pid=$(printf '%s' "$state" | cut -f1)
started=$(printf '%s' "$state" | cut -f2)
log=$(printf '%s' "$state" | cut -f3)
echo "● RUNNING $name ($tid)"
echo " elapsed: $(elapsed_since "$started") pid: $pid"
echo " log: $log"
# `claude -p` writes its answer in one go at the end, so a log
# with only its header is the normal state of a healthy run —
# print the tail only when there is something to show, rather
# than an empty "last output:" that reads like a stall.
# `|| true` throughout: under `set -e` a grep matching nothing
# would otherwise abort the whole command.
local tail_out=""
if [ -f "$log" ]; then
tail_out=$({ grep -v '^===' "$log" || true; } \
| { grep -v '^$' || true; } | tail -n 3)
fi
if [ -n "$tail_out" ]; then
echo " last output:"
printf '%s\n' "$tail_out" | sed 's/^/ /'
fi
elif [ -n "$id" ]; then
echo "○ idle $name ($tid)"
local latest
latest=$(ls -t "$LOGS_DIR/$tid"/*.log 2>/dev/null | head -1) || true
if [ -n "$latest" ]; then
echo " last run: $(basename "$latest" .log) $(grep -o 'Exit code: [0-9]*' "$latest" | tail -1)"
fi
fi
done
if [ "$any" = "false" ] && [ -z "$id" ]; then
echo "Nothing running."
fi
[ "$watch" = "true" ] || break
# Stop watching once the thing being watched has finished.
[ "$any" = "true" ] || break
sleep 5
echo ""
done
}
cmd_logs() { cmd_logs() {
local id="" tail_n=50 local id="" tail_n=50
@@ -413,8 +541,53 @@ cmd_run() {
local name local name
name=$(jq -r '.name' "$task_file") name=$(jq -r '.name' "$task_file")
if run_state "$id" >/dev/null; then
echo "Task '$name' ($id) is already running — see: triple-c-scheduler status --id $id"
return 0
fi
echo "Manually triggering task '$name' ($id)..." echo "Manually triggering task '$name' ($id)..."
/usr/local/bin/triple-c-task-runner "$id"
# Run in the background and stream its log. A task can easily think for
# minutes, and the previous behaviour — block with no output until it is
# over — is indistinguishable from a hang.
/usr/local/bin/triple-c-task-runner "$id" &
local runner_pid=$!
local state="" waited=0
while [ "$waited" -lt 20 ]; do
if state=$(run_state "$id"); then
break
fi
kill -0 "$runner_pid" 2>/dev/null || break
sleep 0.5
waited=$(( waited + 1 ))
done
local log=""
[ -n "$state" ] && log=$(printf '%s' "$state" | cut -f3)
if [ -n "$log" ]; then
echo " log: $log"
echo " elsewhere: triple-c-scheduler status --id $id --watch"
echo ""
# --pid stops the follow when the runner exits, so this returns on its own.
tail -n +1 -f --pid="$runner_pid" "$log" 2>/dev/null
fi
local rc=0
wait "$runner_pid" || rc=$?
# A run short enough that its state file was never observed still deserves
# its output shown rather than swallowed.
if [ -z "$log" ]; then
local latest
latest=$(ls -t "$LOGS_DIR/$id"/*.log 2>/dev/null | head -1) || true
[ -n "$latest" ] && tail -n 20 "$latest"
fi
return $rc
} }
cmd_notifications() { cmd_notifications() {
@@ -464,6 +637,7 @@ case "$command" in
enable) cmd_enable "$@" ;; enable) cmd_enable "$@" ;;
disable) cmd_disable "$@" ;; disable) cmd_disable "$@" ;;
list) cmd_list ;; list) cmd_list ;;
status) cmd_status "$@" ;;
logs) cmd_logs "$@" ;; logs) cmd_logs "$@" ;;
run) cmd_run "$@" ;; run) cmd_run "$@" ;;
notifications) cmd_notifications "$@" ;; notifications) cmd_notifications "$@" ;;
+30
View File
@@ -9,6 +9,7 @@ SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks" TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs" LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications" NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
RUNNING_DIR="${SCHEDULER_DIR}/running"
ENV_FILE="${SCHEDULER_DIR}/.env" ENV_FILE="${SCHEDULER_DIR}/.env"
TASK_ID="${1:-}" TASK_ID="${1:-}"
@@ -34,11 +35,19 @@ if ! flock -n 200; then
fi fi
# ── Source saved environment ───────────────────────────────────────────────── # ── Source saved environment ─────────────────────────────────────────────────
# The env file is a snapshot taken by the entrypoint, which runs as root. A
# snapshot written before the entrypoint stopped capturing HOME still carries
# HOME=/root, and `set -a` would apply it to `claude` below — which then finds no
# credential under /root/.claude and exits with "Not logged in". The env file
# lives on the home volume, so those stale copies outlive an image update until
# the container is restarted; keep our own HOME regardless of what it says.
if [ -f "$ENV_FILE" ]; then if [ -f "$ENV_FILE" ]; then
REAL_HOME="${HOME:-/home/claude}"
set -a set -a
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "$ENV_FILE" source "$ENV_FILE"
set +a set +a
HOME="$REAL_HOME"
fi fi
# ── Read task definition ──────────────────────────────────────────────────── # ── Read task definition ────────────────────────────────────────────────────
@@ -69,6 +78,27 @@ mkdir -p "$TASK_LOG_DIR"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S") TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log" LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log"
# ── Publish run state ───────────────────────────────────────────────────────
# A scheduled run is detached — cron has no terminal, and the app fires it as a
# detached exec — so without this there is no way to tell a task that is still
# thinking from one that died, and a long run reads as a stall. `list`, `status`
# and the app's Automation tab all read this file.
#
# flock above is what actually prevents overlapping runs; this is purely an
# observability record, which is why readers verify the pid rather than trust
# the file. The EXIT trap covers the crash paths (OOM, container stop, SIGTERM)
# that would otherwise leave a task looking like it had been running for days.
mkdir -p "$RUNNING_DIR"
RUN_STATE="${RUNNING_DIR}/${TASK_ID}.json"
trap 'rm -f "$RUN_STATE"' EXIT
jq -n \
--arg pid "$$" \
--arg started "$(date +%s)" \
--arg log "$LOG_FILE" \
--arg name "$TASK_NAME" \
'{pid: ($pid | tonumber), started_epoch: ($started | tonumber), log: $log, name: $name}' \
> "$RUN_STATE"
# ── Execute Claude agent ──────────────────────────────────────────────────── # ── Execute Claude agent ────────────────────────────────────────────────────
{ {
echo "=== Task: $TASK_NAME ($TASK_ID) ===" echo "=== Task: $TASK_NAME ($TASK_ID) ==="