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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/core/cli/spinner.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '
* line and the output is byte-identical to what the callers printed before
* the spinner existed, which is what keeps the scripted transcripts stable.
*
* `quietWhenPlain` writes nothing at all on that plain path. It is for a
* wait sitting in front of the caller's own first output, where the label
* would be all a script ever saw of a delay only a person can perceive, and
* where the elapsed time already reaches the structured log.
*
* The timer never outlives the work: errors clear the line and rethrow.
*
* @template T
Expand All @@ -27,15 +32,16 @@ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '
* label: string,
* env?: NodeJS.ProcessEnv,
* intervalMs?: number,
* quietWhenPlain?: boolean,
* }} opts
* @param {() => Promise<T>} work
* @returns {Promise<T>}
*/
export async function withSpinner(opts, work) {
const { stdout, label, env, intervalMs = 120 } = opts
const { stdout, label, env, intervalMs = 120, quietWhenPlain = false } = opts
const animate = isTty(stdout) && (env ?? process.env).HYP_NO_TUI !== '1'
if (!animate) {
stdout.write(`${label}\n`)
if (!quietWhenPlain) stdout.write(`${label}\n`)
return work()
}

Expand Down
42 changes: 34 additions & 8 deletions src/core/commands/sync.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @ts-check

import { requireConfirmation } from '../cli/confirm.js'
import { withSpinner } from '../cli/spinner.js'
import { parseCommandArgv, STRICT_SHORT_FLAGS } from '../cli/verb_codec.js'
import { Attr, getLogger } from '../observability/index.js'
import { readObservabilityEnv } from '../observability/env.js'
Expand Down Expand Up @@ -161,15 +162,29 @@ export async function runSync(argv, ctx) {
// Counted here rather than inside `renderPlan` so the renderer stays a pure
// function of what it prints, and so a count that fails degrades to "unknown"
// instead of taking the whole prompt down with it.
//
// The count scans every pending partition and runs before this verb has
// printed a character, so a large backlog leaves the terminal blank between
// the keystroke and the plan. `quietWhenPlain` keeps the plan the first
// thing a script sees: there is nobody off a TTY to reassure, and the
// elapsed time is in the log line below.
// @ref LLP 0101#no-release [implements]: the "prints what would leave" half, in rows rather than only in destination names
const previewStartedAt = Date.now()
const volumes = await previewPendingRows({
handles,
query: ctx.query,
storage: /** @type {ExtendedQueryStorageService} */ (ctx.storage),
stateRoot: stateDir,
config: ctx.config,
})
const volumes = await withSpinner(
{
stdout: ctx.stdout,
env: ctx.env,
quietWhenPlain: true,
label: 'Counting pending rows...',
},
() => previewPendingRows({
handles,
query: ctx.query,
storage: /** @type {ExtendedQueryStorageService} */ (ctx.storage),
stateRoot: stateDir,
config: ctx.config,
})
)
// The elapsed time is the point of this line as much as the counts are: the
// preview sits between the user's keystroke and the prompt, so if `hyp sync`
// ever feels hung again the log says whether the count was the reason.
Expand Down Expand Up @@ -332,7 +347,18 @@ async function runHistorySync({ source, handles, destinations, stateDir, deadlin
const previewStartedAt = Date.now()
for (const handle of capable) {
try {
const preview = await handle.sink.previewSourceHistory?.({ source })
// A full scan of the client's retained history, once per destination,
// and like the ordinary plan's count it runs before anything is on
// screen. Quiet off a TTY for the same reason.
const preview = await withSpinner(
{
stdout: ctx.stdout,
env: ctx.env,
quietWhenPlain: true,
label: `Counting retained '${source}' history on ${handle.instanceName}...`,
},
async () => handle.sink.previewSourceHistory?.({ source })
)
if (!preview) throw new Error('history preview became unavailable')
previews.set(handle.instanceName, preview)
} catch (err) {
Expand Down
68 changes: 64 additions & 4 deletions test/core/sync-command.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ function stateDir(hypHome) {
return path.join(hypHome, 'hypaware')
}

function captureStream() {
/** @param {boolean} [isTTY] */
function captureStream(isTTY = false) {
let buf = ''
return {
isTTY,
write(/** @type {string} */ chunk) { buf += String(chunk); return true },
get text() { return buf },
}
Expand Down Expand Up @@ -96,10 +98,14 @@ function fakeHistorySink(instanceName, config) {
}

/**
* @param {{ hypHome: string, sinks: any[], tty?: boolean, answer?: string, remotes?: Record<string, { url: string }> }} args
* `stdoutTty` is separate from `tty` (which is stdin's): the prompt reads
* stdin and the spinner writes stdout, and most of these tests want an
* answerable prompt without an animating stdout.
*
* @param {{ hypHome: string, sinks: any[], tty?: boolean, stdoutTty?: boolean, answer?: string, remotes?: Record<string, { url: string }> }} args
*/
function makeCtx({ hypHome, sinks, tty = false, answer, remotes }) {
const stdout = captureStream()
function makeCtx({ hypHome, sinks, tty = false, stdoutTty = false, answer, remotes }) {
const stdout = captureStream(stdoutTty)
const stderr = captureStream()
const stdin = Object.assign(new PassThrough(), { isTTY: tty })
if (answer !== undefined) stdin.write(`${answer}\n`)
Expand Down Expand Up @@ -209,6 +215,60 @@ test('--dry-run prints the plan, exports nothing, and keeps the window open', as
assert.ok(await holdExists(hypHome))
})

// Both previews are full scans that run before this verb has printed
// anything, so a big backlog leaves the terminal blank for seconds between
// the keystroke and the plan. Off a TTY that path stays byte-identical.
test('the pending preview animates on a TTY and clears before the plan', async () => {
const hypHome = await makeHome('preview-spinner')
const sink = fakeSink('central', { url: 'https://hypaware.example.com' })
// A TTY with no answer queued: `--dry-run` must not reach the prompt.
const { ctx, stdout } = makeCtx({ hypHome, sinks: [sink], tty: true, stdoutTty: true })

assert.equal(await runSync(['--dry-run'], ctx), 0)

const text = stdout.text
assert.match(text, /\r\x1b\[2K\S Counting pending rows/, 'the preview wait animates')
// Transient: every frame is behind a line-clearing carriage return, and the
// plan renders after the last clear rather than under a leftover label.
assert.doesNotMatch(text, /Counting pending rows[^\r]*\n/)
assert.match(text.split('\r\x1b[2K').pop() ?? '', /destination/)
})

test('the pending preview writes nothing off a TTY', async () => {
const hypHome = await makeHome('preview-plain')
const sink = fakeSink('central', { url: 'https://hypaware.example.com' })
const { ctx, stdout } = makeCtx({ hypHome, sinks: [sink] })

assert.equal(await runSync(['--dry-run'], ctx), 0)

assert.doesNotMatch(stdout.text, /Counting pending rows/)
assert.doesNotMatch(stdout.text, /\x1b\[2K/)
})

test('the --history preview animates per destination on a TTY', async () => {
const hypHome = await makeHome('history-preview-spinner')
const central = fakeHistorySink('central', { url: 'https://hypaware.example.com' })
const { ctx, stdout } = makeCtx({ hypHome, sinks: [central], tty: true, stdoutTty: true })

assert.equal(await runSync(['--history', 'claude', '--dry-run'], ctx), 0)

const text = stdout.text
assert.match(text, /\r\x1b\[2K\S Counting retained 'claude' history on central/)
assert.doesNotMatch(text, /Counting retained[^\r]*\n/)
assert.match(text.split('\r\x1b[2K').pop() ?? '', /12 rows retained and eligible/)
})

test('the --history preview writes nothing off a TTY', async () => {
const hypHome = await makeHome('history-preview-plain')
const central = fakeHistorySink('central', { url: 'https://hypaware.example.com' })
const { ctx, stdout } = makeCtx({ hypHome, sinks: [central] })

assert.equal(await runSync(['--history', 'claude', '--dry-run'], ctx), 0)

assert.doesNotMatch(stdout.text, /Counting retained/)
assert.doesNotMatch(stdout.text, /\x1b\[2K/)
})

// @ref LLP 0345#command [tests]: retained history has its own preview,
// confirmation, and execution path, separate from an ordinary sink tick.
test('--history previews capable destinations and sends only after confirmation', async () => {
Expand Down
Loading