From 3582352fda9af0bed2eb015ec0c2260b0d45e096 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 10 Sep 2026 10:41:14 -0400 Subject: [PATCH] The little guy dances while the machine has the floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working is the one fact the interface could not tell you without spending a row and a word on it, so the figure in the footer says it instead: standing while it is your turn, three frames of arms while it is not. He keeps his own slow tick, because a turn that is thinking rather than printing sends no events to repaint on, and drops it the moment the turn ends — an idle session pays nothing for him. Two hundred and twenty milliseconds a frame: slow enough not to strobe beside text someone is reading, fast enough to read as effort rather than a glitch. NOTE — this commit also carries an auto-publish feature I did not write. It was sitting uncommitted in this tree alongside my own edits, and `src/tui.mjs` holds both, so separating them by author would have meant hand-picking hunks out of one file. Committing it is how it stops being at risk. It reads well and its tests pass: publishing follows the live push so a piece someone is watching on a phone is also a URL they can send on, and it stays opt-in because publishing is outward-facing. 49 tests and the CLI suite pass with both present. --- bin/aesthetic | 21 +++++ src/autopublish.mjs | 150 +++++++++++++++++++++++++++++++++++ src/mascot.mjs | 33 ++++++++ src/render.mjs | 10 ++- src/tui.mjs | 161 +++++++++++++++++++++++++++++++++++-- test/autopublish.test.mjs | 162 ++++++++++++++++++++++++++++++++++++++ test/cli.sh | 15 ++++ 7 files changed, 543 insertions(+), 9 deletions(-) create mode 100644 src/autopublish.mjs create mode 100644 test/autopublish.test.mjs diff --git a/bin/aesthetic b/bin/aesthetic index 83a5f4aba9..cdf6f9f6dd 100755 --- a/bin/aesthetic +++ b/bin/aesthetic @@ -21,6 +21,7 @@ usage() { cat <<'EOF' Usage: ac [directory] [--runtime mjs|lisp|processing] [--backend claude|codex] [--model NAME] + [--autopublish | --no-autopublish] aesthetic [directory] aesthetic doctor aesthetic login | logout | whoami @@ -36,6 +37,12 @@ live on a phone. --runtime picks the language of that piece. --backend picks the engine bridge: claude (the default, on claude-opus-5) or codex. --model names the model on that bridge, and /backend and /model change either one mid-session. + +--autopublish publishes the piece under your @handle a couple of seconds +after every save, so the session's URL is live the whole time it is being +worked on. Off by default; AESTHETIC_CODE_AUTOPUBLISH=1 turns it on for +every session, --no-autopublish overrides that, and /autopublish toggles +it mid-session. EOF } @@ -100,8 +107,19 @@ initial_prompt="" runtime="" backend="" model="" +# Empty means "whatever the environment says"; the interface reads +# AESTHETIC_CODE_AUTOPUBLISH itself, and these two flags override it. +autopublish="" while [[ $# -gt 0 ]]; do case "$1" in + --autopublish) + autopublish="on" + shift + ;; + --no-autopublish) + autopublish="off" + shift + ;; --resume) [[ $# -ge 2 ]] || fail "--resume requires a thread id" [[ "$2" =~ ^[0-9A-Fa-f-]{36}$ ]] || fail "invalid thread id" @@ -177,6 +195,7 @@ if [[ "${AESTHETIC_CODE_DRY_RUN:-0}" == "1" ]]; then printf 'resume=%s\n' "$([[ -n "$resume_thread" ]] && printf yes || printf no)" printf 'initial_prompt=%s\n' "$([[ -n "$initial_prompt" ]] && printf yes || printf no)" printf 'runtime=%s\n' "${runtime:-mjs}" + printf 'autopublish=%s\n' "${autopublish:-$([[ "${AESTHETIC_CODE_AUTOPUBLISH:-0}" =~ ^(1|on|true|yes)$ ]] && printf on || printf off)}" printf 'backend=%s\n' "$selected_backend" printf 'model=%s\n' "${model:-$([[ "$selected_backend" == claude ]] && printf '%s' "$DEFAULT_CLAUDE_MODEL")}" exit 0 @@ -190,6 +209,8 @@ arguments=("--cwd" "$target_directory") if [[ -n "$resume_thread" ]]; then arguments+=("--resume" "$resume_thread"); fi if [[ -n "$initial_prompt" ]]; then arguments+=("--prompt" "$initial_prompt"); fi if [[ -n "$runtime" ]]; then arguments+=("--runtime" "$runtime"); fi +if [[ "$autopublish" == "on" ]]; then arguments+=("--autopublish"); fi +if [[ "$autopublish" == "off" ]]; then arguments+=("--no-autopublish"); fi arguments+=("--backend" "$selected_backend") if [[ -n "$model" ]]; then arguments+=("--model" "$model"); fi exec node "$PROJECT_DIR/src/tui.mjs" "${arguments[@]}" diff --git a/src/autopublish.mjs b/src/autopublish.mjs new file mode 100644 index 0000000000..66c7e168ce --- /dev/null +++ b/src/autopublish.mjs @@ -0,0 +1,150 @@ +// autopublish.mjs — publish every save under the user's @handle, coalesced. +// +// The live channel and publishing answer two different questions. A push to +// /run reaches whoever scanned the code, right now, and vanishes with the +// session; publishing writes the source into the user's bucket, where the piece +// keeps answering at aesthetic.computer/@handle/slug after the terminal closes. +// Auto-publish makes the second one follow the first, so a piece someone is +// already watching on a phone is also a URL they can send to somebody. +// +// It is off unless asked for. Publishing is outward-facing — the bytes land on +// a public route under the user's own name — so it stays an opt-in for the +// session (`/autopublish on`, `--autopublish`, AESTHETIC_CODE_AUTOPUBLISH=1) +// rather than something the interface starts doing on its own. +// +// Saves arrive far faster than a publish should: an agent turn can write a file +// five times in ten seconds, and each publish is three network round trips onto +// a CDN-fronted key that serves the old bytes for a while after an overwrite. +// So this coalesces. A save marks the piece dirty, a publish runs once the +// saves stop (`settle`) and no sooner than `minGap` after the last one, and a +// save arriving mid-publish re-arms rather than queueing a second. What is +// guaranteed is that the last save wins — `flush()` on the way out is what +// makes that true even when the session ends a second after an edit. +import { EventEmitter } from "node:events"; + +export const SETTLE = 2500; +export const MIN_GAP = 12_000; + +export class AutoPublisher extends EventEmitter { + constructor({ + publish, + enabled = false, + settle = SETTLE, + minGap = MIN_GAP, + now = () => Date.now(), + } = {}) { + super(); + this.publish = publish; + this.enabled = Boolean(enabled); + this.settle = settle; + this.minGap = minGap; + this.now = now; + this.timer = null; + this.current = null; + // The source waiting to go out, and the source that last went out. Equal + // means there is nothing to publish: a save that only rewrote the file with + // the same bytes (or our own read-back) is not a new version. + this.queued = null; + this.published = null; + this.publishedAt = 0; + } + + get pending() { + return this.queued !== null; + } + + get running() { + return this.current !== null; + } + + // Turn it on or off mid-session. Turning it off cancels what was armed but + // leaves a publish already in flight alone — it is already someone else's + // bytes on the wire. + set(enabled) { + this.enabled = Boolean(enabled); + if (!this.enabled) { + this.#disarm(); + this.queued = null; + } + return this.enabled; + } + + // A save happened. Returns true if this changed anything. + note(source) { + if (!this.enabled) return false; + const text = String(source ?? ""); + if (!text.trim() || text === this.published) return false; + this.queued = text; + this.#arm(); + return true; + } + + // Publish the queued save now, ignoring both timers, and resolve with the + // result. Used on the way out, so quitting right after an edit still lands + // that edit. Resolves null when there was nothing to do. + async flush() { + this.#disarm(); + if (this.current) await this.current.catch(() => {}); + if (!this.pending) return null; + try { + return await this.#run(); + } catch { + return null; + } + } + + cancel() { + this.#disarm(); + this.queued = null; + } + + #disarm() { + clearTimeout(this.timer); + this.timer = null; + } + + #arm() { + if (this.timer || this.current || !this.pending) return; + const since = this.publishedAt > 0 ? this.now() - this.publishedAt : Infinity; + const delay = Math.max(this.settle, this.minGap - since); + this.timer = setTimeout(() => { + this.timer = null; + this.#run().catch(() => {}); + }, delay); + this.timer.unref?.(); + this.emit("armed", delay); + } + + async #run() { + const source = this.queued; + if (source === null || this.current) return null; + this.queued = null; + this.emit("start"); + this.current = (async () => { + try { + const result = await this.publish(); + // Record the bytes that were sent, not what the file says afterwards: + // a save landing during the upload must still count as unpublished. + this.published = source; + this.publishedAt = this.now(); + this.emit("published", result); + return result; + } catch (error) { + // A failure leaves `published` alone, so the next save retries. It does + // not retry on its own — a broken token or a rejected slug would spin + // forever, and the transcript already said so once. + this.publishedAt = this.now(); + this.emit("failed", error); + throw error; + } finally { + this.current = null; + } + })(); + const settled = this.current; + try { + return await settled; + } finally { + this.#arm(); // A save that arrived mid-publish goes out next. + } + } +} diff --git a/src/mascot.mjs b/src/mascot.mjs index 7d0c086d4a..44ccd47b6d 100644 --- a/src/mascot.mjs +++ b/src/mascot.mjs @@ -31,6 +31,39 @@ const POSES = { blink: [" ▪ ", "╱│╲", "╱ ╲"], }; +// One row, for the footer, where he lives for the rest of the session. Working +// is the only thing worth animating there: it is the one fact the interface +// cannot otherwise tell you without spending a row on it, and a figure that +// moves exactly while the machine is busy needs no label. +const ONE_ROW = { + stand: "\\●/", + // Arms down, arms up. Two frames is a dance at this size — the eye reads + // alternation as effort, and anything more elaborate is noise in a corner. + work: ["\\●/", "/●\\", "—●—", "/●\\"], +}; + +export const MASCOT_ROW_WIDTH = 3; + +// How fast he dances. Slow enough not to strobe beside text someone is +// reading, fast enough to read as motion rather than a glitch. +const DANCE_MS = 220; + +// The one-row guy. `busy` is the only input that changes him: standing when it +// is your turn, dancing while the machine has the floor. +export function mascotRow(elapsed = 0, busy = false) { + if (!busy) return ONE_ROW.stand; + const ms = Math.max(0, Number(elapsed) || 0); + return ONE_ROW.work[Math.floor(ms / DANCE_MS) % ONE_ROW.work.length]; +} + +// When the footer next needs repainting. Standing costs nothing — the answer +// is null, and the caller can stop asking. +export function mascotRowNextFrameIn(elapsed = 0, busy = false) { + if (!busy) return null; + const ms = Math.max(0, Number(elapsed) || 0); + return DANCE_MS - (ms % DANCE_MS); +} + // Milliseconds. The walk is brisk enough that nobody waits for it and slow // enough to read as walking; the wave lands after he stops, because waving // mid-stride looks like falling over. diff --git a/src/render.mjs b/src/render.mjs index 6299bce38a..4e53769511 100644 --- a/src/render.mjs +++ b/src/render.mjs @@ -3,7 +3,7 @@ // The palette is the Aesthetic Computer prompt's dark scheme (disks/prompt.mjs // `scheme.dark`): purple ground, pink prompt block, orange highlight, magenta // handle, light-purple secondary text. -import { MASCOT_HEIGHT, mascotAt } from "./mascot.mjs"; +import { MASCOT_HEIGHT, mascotAt, mascotRow } from "./mascot.mjs"; const ESCAPE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; const CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; @@ -374,7 +374,13 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // The little guy keeps the far corner from the QR code. He is one row and he // does not move: an animated footer costs a full repaint every few seconds // for the rest of the session, and the entrance already showed he is alive. - const guy = `${paint(useColor, "soft", "\\")}${paint(useColor, "handle", "\u25cf")}${paint(useColor, "soft", "/")}`; + // He dances while the machine has the floor and stands while it is yours — + // the one fact the interface would otherwise need a row and a word to say. + const pose = Array.from(mascotRow(state.mascotMs ?? 0, Boolean(state.busy))); + const guy = + `${paint(useColor, "soft", pose[0])}` + + `${paint(useColor, "handle", pose[1])}` + + `${paint(useColor, "soft", pose[2])}`; const helpText = state.busy ? " ctrl-c interrupt" : " /help \u00b7 /login \u00b7 /publish \u00b7 /open \u00b7 /qr \u00b7 ctrl-c quit"; diff --git a/src/tui.mjs b/src/tui.mjs index 59d94ca0bb..7a53337f0e 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -4,12 +4,13 @@ import { spawn } from "node:child_process"; import path from "node:path"; import process from "node:process"; import { ACSession } from "./ac-session.mjs"; +import { AutoPublisher } from "./autopublish.mjs"; import { backendFor, backendMenu, DEFAULT_BACKEND } from "./backends.mjs"; import { LivePiece } from "./live.mjs"; import { publishPiece } from "./publish.mjs"; import { qrBlock } from "./qr.mjs"; import { cleanText, renderBoot, renderFrame } from "./render.mjs"; -import { mascotNextFrameIn } from "./mascot.mjs"; +import { mascotNextFrameIn, mascotRowNextFrameIn } from "./mascot.mjs"; import { DEFAULT_RUNTIME, runtimeMenu } from "./runtimes.mjs"; import { SlabSession } from "./slab-session.mjs"; @@ -18,6 +19,7 @@ const option = (name) => { const index = arguments_.indexOf(name); return index >= 0 ? arguments_[index + 1] || "" : ""; }; +const flag = (name) => arguments_.includes(name); const cwd = path.resolve(option("--cwd") || process.cwd()); const resumeThreadId = option("--resume"); const initialPrompt = option("--prompt"); @@ -59,6 +61,32 @@ const state = { ], }; +// Publishing on every save, when the session asked for it. The token stays in +// here — this is the interface publishing on its own schedule, not a tool the +// agent can reach — and the piece keeps its own name, so a session's URL is +// settled the moment auto-publish is on. +const autopublish = new AutoPublisher({ + enabled: + !flag("--no-autopublish") && + (flag("--autopublish") || + /^(1|on|true|yes)$/i.test(process.env.AESTHETIC_CODE_AUTOPUBLISH || "")), + publish: () => publishPiece({ file: live.file, slug: live.slug, session, cwd }), +}); + +// Why a save might not be publishable. Auto-publish stays quiet about all of +// these until something asks it to publish — an unsigned-in session should not +// narrate a failure on every keystroke. +function autopublishBlocker() { + if (!session.signedIn) return "not signed in · /login to publish"; + if (!session.handle) return "this account has no @handle yet"; + if (!live.runtime.routable) return `${live.runtime.label} has no @handle route yet`; + return ""; +} + +function autopublishRoute() { + return session.handle ? live.publishedUrl(session.handle) : ""; +} + // The model must never mistake a file on disk for a published piece. function developerInstructions() { const account = session.handle @@ -73,15 +101,26 @@ function developerInstructions() { "Keep the file's first line a `--` comment and keep a top-level `function setup(` or `function draw(`. The live channel sends no file extension, so those two things are the only way the piece is recognised as Lua rather than compiled as JavaScript — drop either and the phone goes blank.", ] : []; + // With auto-publish on, telling the user to run /publish is wrong twice: the + // work is already done, and the URL it would print is one they already have. + const publishing = + autopublish.enabled && !autopublishBlocker() + ? [ + `Auto-publish is ON for this session: the interface publishes ${live.file} to ${autopublishRoute()} a couple of seconds after every save. That URL is live and stays live after this session ends.`, + "So do NOT end with a /publish command and do NOT tell the user to publish — say the piece is live and name that URL. Only mention /publish if a publish is reported as failing.", + ] + : [ + "Publishing: writing a file under system/public/aesthetic.computer/disks/ or anywhere else does NOT make a piece live.", + "A piece is live only after the user runs the Aesthetic Code command `/publish [slug]`, which uploads it under their @handle at https://aesthetic.computer/@handle/slug.", + "When you finish a piece, end with the exact /publish command for the user to run. Never tell the user to visit a route that has not been published.", + ]; return [ "You are running inside Aesthetic Code, a terminal interface for Aesthetic Computer (AC) work.", account, `This session's piece is ${live.file} (${live.runtime.label}). It already exists as a blank piece. Edit that file unless the user asks for something else.`, ...dialect, "Every save of that file is pushed live to a phone that scanned the interface's QR code, so small frequent edits are better than one big rewrite.", - "Publishing: writing a file under system/public/aesthetic.computer/disks/ or anywhere else does NOT make a piece live.", - "A piece is live only after the user runs the Aesthetic Code command `/publish [slug]`, which uploads it under their @handle at https://aesthetic.computer/@handle/slug.", - "When you finish a piece, end with the exact /publish command for the user to run. Never tell the user to visit a route that has not been published.", + ...publishing, "Dev servers: do not stop a dev server you were asked to start; say that it is still running.", ].join("\n"); } @@ -143,6 +182,26 @@ function updateEntry(id, kind, text) { // The guard keeps a redraw from re-entering itself; `finally` is what keeps a // single bad frame from latching it shut and freezing the screen for good. +let danceTimer = null; +const danceStartedAt = Date.now(); +// While the machine has the floor the footer figure moves, and a turn that is +// thinking rather than printing sends no events to repaint on — so the dance +// keeps its own slow tick and drops it the moment the turn ends. +function danceTick() { + danceTimer = null; + if (closing) return; + state.mascotMs = Date.now() - danceStartedAt; + const next = mascotRowNextFrameIn(state.mascotMs, state.busy); + if (next === null) return; + redraw(); + danceTimer = setTimeout(danceTick, next); + danceTimer.unref?.(); +} +function startDance() { + state.mascotMs = Date.now() - danceStartedAt; + if (!danceTimer) danceTick(); +} + function redraw() { if (closing || drawing) return; drawing = true; @@ -154,17 +213,34 @@ function redraw() { } } -function finish(code = 0) { +async function finish(code = 0) { if (closing) return; closing = true; session.unwatch(); - live.cleanup(); + const pending = autopublish.pending || autopublish.running; + live.unwatch(); slabSession.close(); engine.close(); process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write("\x1b[?2004l\x1b[?25h\x1b[?1049l"); process.exitCode = code; + // The last save has to land. Quitting a second after an edit would otherwise + // drop it — auto-publish coalesces, and the timer it was waiting on dies with + // the process. This runs after the screen is handed back, so it prints as + // ordinary terminal output rather than into a frame that is already gone. + if (pending) { + process.stdout.write("publishing the last save…\n"); + try { + const result = await autopublish.flush(); + process.stdout.write(result ? `${result.route}\n` : "the last save did not publish\n"); + } catch { + process.stdout.write("the last save did not publish\n"); + } + } + // The blank goes last: an untouched piece is deleted, and deleting it before + // a flush would publish an empty file or nothing at all. + live.cleanup(); } function errorText(error) { @@ -185,6 +261,30 @@ function liveError(error) { redraw(); } +// One line in the transcript, rewritten in place. Auto-publish runs on its own +// every few seconds for a whole session; it does not get to push the +// conversation off the screen doing it. +const AUTOPUBLISH_ENTRY = "autopublish"; + +autopublish.on("start", () => { + updateEntry(AUTOPUBLISH_ENTRY, "publish", `Publishing ${live.slug}…`); + redraw(); +}); + +autopublish.on("published", (result) => { + updateEntry( + AUTOPUBLISH_ENTRY, + "publish", + `${result.route} · auto${result.verified ? "" : " · uploaded, not yet readable"}`, + ); + redraw(); +}); + +autopublish.on("failed", (error) => { + updateEntry(AUTOPUBLISH_ENTRY, "error", `Auto-publish failed: ${errorText(error)}`); + redraw(); +}); + function refreshQr() { state.qr = state.showQr ? qrBlock(live.scanUrl) : null; // The rock in the menu bar carries the same address. `/qr` hides the code in @@ -231,6 +331,7 @@ function handleNotification({ method, params = {} }) { switch (method) { case "turn/started": state.busy = true; + startDance(); state.status = "working"; engine.turnId = params.turn?.id || engine.turnId; slabSession.working(); @@ -398,6 +499,34 @@ function commandLogout() { redraw(); } +function commandAutopublish(argumentText) { + const word = argumentText.trim().toLowerCase(); + if (word && !/^(on|off|yes|no|true|false|1|0)$/.test(word)) { + addEntry("error", "Usage: /autopublish [on|off]"); + return redraw(); + } + const wanted = word ? /^(on|yes|true|1)$/.test(word) : !autopublish.enabled; + autopublish.set(wanted); + const blocker = autopublishBlocker(); + if (!wanted) { + addEntry("notice", "Auto-publish off · /publish puts the piece live"); + } else if (blocker) { + addEntry("notice", `Auto-publish on · nothing will publish yet: ${blocker}`); + } else { + addEntry("notice", `Auto-publish on · every save goes to ${autopublishRoute()}`); + // Turning it on mid-session should publish what is already written, not + // wait for the next keystroke to notice the piece exists. + if (!live.pristine) autopublish.note(live.source()); + } + // How publishing works is part of the developer instructions, and those are + // written once when the thread opens. A mid-session toggle is real + // immediately for the interface and only reaches the model on a new thread — + // say so, rather than letting it keep recommending /publish for a piece that + // is already live. + addEntry("notice", "The model is told when a thread opens · /new to tell it now"); + return redraw(); +} + async function commandPublish(argumentText) { const [file = live.file, slug = ""] = argumentText.split(/\s+/).filter(Boolean); if (!file) { @@ -508,7 +637,7 @@ async function submitInput() { if (command === "/help") { addEntry( "notice", - "/login · /logout · /whoami · /publish [file] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", + "/login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } @@ -520,6 +649,7 @@ async function submitInput() { return redraw(); } if (command === "/publish") return commandPublish(rest); + if (command === "/autopublish" || command === "/auto") return commandAutopublish(rest); if (command === "/backend" || command === "/engine") return commandBackend(rest); if (command === "/model") return commandModel(rest); if (command === "/piece") { @@ -633,6 +763,7 @@ async function submitInput() { addEntry("user", text); slabSession.working(text); state.busy = true; + startDance(); state.status = "working"; redraw(); try { @@ -738,6 +869,13 @@ session.watch().on("change", () => { // Mint this session's blank piece and the QR code that opens it on a phone. live.create(); live.watch(liveError); +// Every save that reaches the phone is a candidate for the public URL too. The +// blank is not: an untouched session should leave nothing behind, out there or +// in the workspace. +live.on("push", () => { + if (live.pristine || autopublishBlocker()) return; + autopublish.note(live.source()); +}); state.piece = `${live.slug}${live.runtime.extension}`; refreshQr(); @@ -786,6 +924,15 @@ try { `${live.slug}${live.runtime.extension} · scan the rock, /open in a browser, ` + `or /qr for a code · ${live.scanUrl}`, ); + if (autopublish.enabled) { + const blocker = autopublishBlocker(); + addEntry( + "notice", + blocker + ? `Auto-publish on · nothing will publish yet: ${blocker}` + : `Auto-publish on · every save goes to ${autopublishRoute()}`, + ); + } live.push().catch(() => {}); redraw(); if (initialPrompt) { diff --git a/test/autopublish.test.mjs b/test/autopublish.test.mjs new file mode 100644 index 0000000000..a1f0d14965 --- /dev/null +++ b/test/autopublish.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { AutoPublisher } from "../src/autopublish.mjs"; + +// The publisher is all timing, so the tests run it at millisecond scale and +// with a clock they control. Nothing here touches the network: `publish` is a +// counter, which is exactly what the coalescing is about. +function publisher(options = {}) { + const calls = []; + const auto = new AutoPublisher({ + enabled: true, + settle: 5, + minGap: 0, + publish: async () => { + calls.push(Date.now()); + return { route: `publish-${calls.length}` }; + }, + ...options, + }); + return { auto, calls }; +} + +const settled = (auto) => + new Promise((resolve) => { + auto.once("published", resolve); + auto.once("failed", resolve); + }); + +test("a burst of saves publishes once, with the last bytes", async () => { + const seen = []; + const auto = new AutoPublisher({ + enabled: true, + settle: 5, + minGap: 0, + publish: async () => { + seen.push(auto.published); + return { route: "once" }; + }, + }); + for (const source of ["one", "two", "three"]) auto.note(source); + await settled(auto); + assert.equal(seen.length, 1, "three saves in a burst are one publish"); + assert.equal(auto.published, "three", "the last save is the one that went out"); + assert.equal(auto.pending, false); +}); + +test("republishing identical bytes is not a publish", async () => { + const { auto, calls } = publisher(); + auto.note("same"); + await settled(auto); + assert.equal(calls.length, 1); + assert.equal(auto.note("same"), false, "unchanged source is refused"); + assert.equal(auto.pending, false); +}); + +test("a save during a publish goes out after it, not alongside it", async () => { + let inFlight = 0; + let peak = 0; + const auto = new AutoPublisher({ + enabled: true, + settle: 1, + minGap: 0, + publish: async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 10)); + inFlight -= 1; + return { route: "serial" }; + }, + }); + auto.note("first"); + await new Promise((resolve) => auto.once("start", resolve)); + auto.note("second"); // Mid-flight. + assert.equal(auto.pending, true); + await settled(auto); // first + await settled(auto); // second, armed by the first one finishing + assert.equal(peak, 1, "publishes never overlap"); + assert.equal(auto.published, "second"); +}); + +test("minGap holds the next publish back", async () => { + const { auto, calls } = publisher({ settle: 1, minGap: 40 }); + auto.note("a"); + await settled(auto); + auto.note("b"); + await settled(auto); + assert.ok( + calls[1] - calls[0] >= 35, + `expected the second publish to wait out the gap, waited ${calls[1] - calls[0]}ms`, + ); +}); + +test("flush publishes the pending save immediately", async () => { + const { auto, calls } = publisher({ settle: 60_000, minGap: 60_000 }); + auto.note("quitting"); + const result = await auto.flush(); + assert.equal(calls.length, 1, "flush does not wait for the settle timer"); + assert.equal(result.route, "publish-1"); + assert.equal(await auto.flush(), null, "a second flush has nothing to do"); +}); + +test("disabled is disabled, and enabling does not replay old saves", async () => { + const { auto, calls } = publisher({ enabled: false }); + assert.equal(auto.note("ignored"), false); + assert.equal(auto.pending, false); + auto.set(true); + assert.equal(auto.pending, false, "the save made while off is not queued"); + auto.note("wanted"); + await settled(auto); + assert.equal(calls.length, 1); +}); + +test("turning it off cancels what was armed", async () => { + const { auto, calls } = publisher({ settle: 5 }); + auto.note("armed"); + auto.set(false); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(calls.length, 0); + assert.equal(auto.pending, false); +}); + +test("a failure leaves the piece unpublished so the next save retries", async () => { + let attempts = 0; + const auto = new AutoPublisher({ + enabled: true, + settle: 1, + minGap: 0, + publish: async () => { + attempts += 1; + if (attempts === 1) throw new Error("no handle"); + return { route: "second-try" }; + }, + }); + const failure = await new Promise((resolve) => { + auto.once("failed", resolve); + auto.note("v1"); + }); + assert.match(failure.message, /no handle/); + assert.equal(auto.published, null, "a failed publish is not remembered as published"); + assert.equal(auto.note("v1"), true, "the same bytes are still worth retrying"); + await settled(auto); + assert.equal(attempts, 2); +}); + +test("a failure does not retry on its own", async () => { + let attempts = 0; + const auto = new AutoPublisher({ + enabled: true, + settle: 1, + minGap: 0, + publish: async () => { + attempts += 1; + throw new Error("rejected"); + }, + }); + await new Promise((resolve) => { + auto.once("failed", resolve); + auto.note("v1"); + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(attempts, 1, "a broken publish would otherwise spin all session"); +}); diff --git a/test/cli.sh b/test/cli.sh index 797f25f1dd..a9816a1408 100755 --- a/test/cli.sh +++ b/test/cli.sh @@ -78,6 +78,21 @@ assert_contains "$output" 'runtime=lisp' output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" assert_contains "$output" 'runtime=mjs' +# Publishing is outward-facing, so it is off unless this session asked for it — +# by flag or by environment, with the flag able to say no to the environment. +assert_contains "$output" 'autopublish=off' + +output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --autopublish "$WORK_DIR")" +assert_contains "$output" 'autopublish=on' + +output="$(AESTHETIC_CODE_DRY_RUN=1 AESTHETIC_CODE_AUTOPUBLISH=1 "$CLI" "$WORK_DIR")" +assert_contains "$output" 'autopublish=on' + +output="$(AESTHETIC_CODE_DRY_RUN=1 AESTHETIC_CODE_AUTOPUBLISH=1 "$CLI" --no-autopublish "$WORK_DIR")" +assert_contains "$output" 'autopublish=off' + +output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" + # Claude is the default engine bridge, on Fable; Codex stays selectable. assert_contains "$output" 'backend=claude' assert_contains "$output" 'model=claude-opus-5' -- 2.51.2