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
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ Notes:

## parallax logs

Tail logs from orchestrator API.
Tail new logs from orchestrator API starting from when the command begins.

```bash
parallax logs [--task <id>]
Expand Down
28 changes: 21 additions & 7 deletions packages/cli/src/commands/logs.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
import chalk from 'chalk'
import { sleep } from '@parallax/common'
import { parseLogsOptions } from '../args.js'
import type { CliContext } from '../types.js'

type TaskLogsApiRecord = {
export type TaskLogsApiRecord = {
taskExternalId: string
message: string
icon: string
level: 'info' | 'warning' | 'error'
timestamp: number
}

function formatTimestamp(epochMs: number): string {
return new Date(epochMs).toISOString()
export function formatLogLine(entry: TaskLogsApiRecord, colors: typeof chalk = chalk): string {
const timestamp = colors.dim(new Date(entry.timestamp).toISOString())
const taskExternalId = colors.magenta(`[${entry.taskExternalId}]`)
const level =
entry.level === 'warning'
? colors.yellow(entry.level.toUpperCase())
: entry.level === 'error'
? colors.red(entry.level.toUpperCase())
: colors.blue(entry.level.toUpperCase())
const icon =
entry.level === 'warning'
? colors.yellow(entry.icon)
: entry.level === 'error'
? colors.red(entry.icon)
: colors.blue(entry.icon)

return `${timestamp} ${taskExternalId} ${level} ${icon} ${entry.message}`
}

async function fetchJson<T>(url: string): Promise<T> {
Expand All @@ -26,7 +42,7 @@ async function fetchJson<T>(url: string): Promise<T> {
export async function runLogs(args: string[], context: CliContext) {
const options = parseLogsOptions(args)
const apiBase = await context.resolveDefaultApiBase()
let cursor = 0
let cursor = Date.now()
let seenAtCursor = new Set<string>()

while (true) {
Expand All @@ -51,9 +67,7 @@ export async function runLogs(args: string[], context: CliContext) {
continue
}

console.log(
`${formatTimestamp(entry.timestamp)} [${entry.taskExternalId}] ${entry.level.toUpperCase()} ${entry.icon} ${entry.message}`
)
console.log(formatLogLine(entry))
if (entry.timestamp > cursor) {
cursor = entry.timestamp
seenAtCursor = new Set<string>()
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ Commands:
retry Queue a task for manual retry.
cancel Cancel a pending or running task.
stop Force-stop the running Parallax processes.
logs Tail task logs from the running Parallax API.`)
logs Tail new task logs from the running Parallax API.`)
}
144 changes: 144 additions & 0 deletions packages/cli/test/logs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import chalk from 'chalk'
import stripAnsi from 'strip-ansi'
import type { CliContext } from '../src/types.js'

const stopLoop = new Error('stop loop')

const { sleepMock } = vi.hoisted(() => ({
sleepMock: vi.fn(),
}))

vi.mock('@parallax/common', () => ({
sleep: sleepMock,
}))

import { formatLogLine, runLogs } from '../src/commands/logs.js'

function createContext(overrides: Partial<CliContext> = {}): CliContext {
return {
defaultApiBase: 'http://localhost:3000',
defaultDataDir: '/tmp/.parallax',
manifestFile: 'running.json',
registryFile: 'registry.json',
rootDir: '/tmp/parallax',
cliVersion: '0.0.8',
packageVersion: '0.0.8',
resolvePath: (raw) => raw,
ensureFileExists: async () => true,
loadRunningState: async () => ({
startedAt: Date.now(),
orchestratorPid: 1,
apiPort: 3000,
uiPort: 8080,
}),
loadRegistry: async () => ({ configs: [] }),
saveRegistry: async () => {},
resolveDefaultApiBase: async () => 'http://localhost:3000',
validateConfigFile: async () => {},
buildEnvConfig: () => ({}),
...overrides,
}
}

describe('runLogs', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn())
})

it('starts tailing from the current time instead of replaying old logs', async () => {
vi.spyOn(Date, 'now').mockReturnValue(5_000)
sleepMock.mockRejectedValue(stopLoop)
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ logs: [] }),
} as Response)

await expect(runLogs([], createContext())).rejects.toBe(stopLoop)

expect(fetch).toHaveBeenCalledWith('http://localhost:3000/logs?since=5000&limit=500')
})

it('prints only new entries once while preserving the existing output shape', async () => {
vi.spyOn(Date, 'now').mockReturnValue(5_000)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

sleepMock
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(stopLoop)

vi.mocked(fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
logs: [
{
taskExternalId: 'old-task',
level: 'info',
icon: 'ℹ',
message: 'should be skipped',
timestamp: 4_999,
},
{
taskExternalId: 'task-1',
level: 'warning',
icon: '⚠',
message: 'first fresh log',
timestamp: 5_000,
},
],
}),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
logs: [
{
taskExternalId: 'task-1',
level: 'warning',
icon: '⚠',
message: 'first fresh log',
timestamp: 5_000,
},
{
taskExternalId: 'task-2',
level: 'error',
icon: '✖',
message: 'second fresh log',
timestamp: 5_001,
},
],
}),
} as Response)

await expect(runLogs([], createContext())).rejects.toBe(stopLoop)

expect(logSpy).toHaveBeenCalledTimes(2)
expect(stripAnsi(String(logSpy.mock.calls[0]?.[0]))).toBe(
'1970-01-01T00:00:05.000Z [task-1] WARNING ⚠ first fresh log'
)
expect(stripAnsi(String(logSpy.mock.calls[1]?.[0]))).toBe(
'1970-01-01T00:00:05.001Z [task-2] ERROR ✖ second fresh log'
)
expect(fetch).toHaveBeenNthCalledWith(2, 'http://localhost:3000/logs?since=5000&limit=500')
})

it('applies severity-based ANSI styling without changing the readable text', () => {
const colors = new chalk.Instance({ level: 1 })
const line = formatLogLine(
{
taskExternalId: 'task-9',
level: 'warning',
icon: '⚠',
message: 'needs attention',
timestamp: 5_000,
},
colors
)

expect(stripAnsi(line)).toBe('1970-01-01T00:00:05.000Z [task-9] WARNING ⚠ needs attention')
expect(line).not.toBe(stripAnsi(line))
})
})
Loading