Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4a8805e
feat(compute): add `compute logs <id>` — container stdout/stderr from…
tonychang04 Aug 27, 2026
51e7e36
fix(compute-logs): sanitize terminal escapes in human output; documen…
tonychang04 Aug 27, 2026
7574fca
fix(compute-logs): address review round 2 — telemetry, C1 controls, f…
tonychang04 Aug 27, 2026
ae71eba
chore(compute-logs): lint — const result, drop unused test import
tonychang04 Aug 27, 2026
db86956
chore(compute-logs): type test callbacks for tsc
tonychang04 Aug 27, 2026
5e2168d
fix(compute-logs): follow-loop retry, readable multi-line messages, i…
tonychang04 Aug 27, 2026
7d29de0
fix(compute-logs): same-timestamp dedupe keys; sanitize region/instance
tonychang04 Aug 27, 2026
01f4759
fix(compute-logs): clear stale follow cursor when the server stops re…
tonychang04 Aug 27, 2026
6b0dcea
fix(compute-logs): dedupe every follow poll — frozen cursor can't rep…
tonychang04 Aug 27, 2026
6bbdf43
fix(compute-logs): boundary dedupe by occurrence count, region in key
tonychang04 Aug 27, 2026
525a8b7
fix(compute-logs): only dedupe when the cursor did not advance
tonychang04 Aug 27, 2026
bd25fde
fix(compute-logs): don't block the tail on telemetry flush; handle un…
tonychang04 Aug 27, 2026
cd3bb32
fix(compute-logs): guard the follow watermark against NaN, future, an…
tonychang04 Aug 27, 2026
2cb375d
fix(compute-logs): treat implausibly future timestamps as undated eve…
tonychang04 Aug 27, 2026
1b94f6f
fix(compute-logs): scope the future-timestamp bound per page, not to …
tonychang04 Aug 27, 2026
e514b59
fix(compute-logs): make clock trust sticky, not per-page
tonychang04 Aug 28, 2026
e1ca55c
fix(compute-logs): key the undated dedupe on timestamp too
tonychang04 Aug 28, 2026
e784f82
fix(compute-logs): retry the initial fetch in follow mode too
tonychang04 Aug 28, 2026
67ba77d
fix(compute-logs): announce the tail before the first fetch, and each…
tonychang04 Aug 28, 2026
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: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,16 @@ Get compute service machine events (start/stop/exit/restart).
npx @insforge/cli compute events my-api --limit 50
```

#### `npx @insforge/cli compute logs <id>`

Get container stdout/stderr (application logs) — the same data as the dashboard's Logs panel. Use `compute events` for machine lifecycle events instead.

```bash
npx @insforge/cli compute logs my-api --limit 200
npx @insforge/cli compute logs my-api --follow # poll for new lines every 2s
npx @insforge/cli --json compute logs my-api # { lines, nextToken } — pass nextToken back via --next-token to page forward
```

#### `npx @insforge/cli compute delete <id>`

Delete a compute service and its Fly.io resources.
Expand Down
6 changes: 3 additions & 3 deletions src/commands/compute/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { reportCliUsage } from '../../lib/skills.js';
import { trackCommandUsage } from '../../lib/command-telemetry.js';

// `compute events <id>` returns Fly machine lifecycle events (start/stop/exit/
// restart) — not container stdout/stderr. The previous name `compute logs`
// was misleading; container log streaming is roadmap work and will reuse the
// freshly-vacated `logs` command name when it lands.
// restart) — not container stdout/stderr. For container logs use
// `compute logs <id>`. (This command was originally named `compute logs`,
// which was misleading.)
export function registerComputeEventsCommand(computeCmd: Command): void {
computeCmd
.command('events <id>')
Expand Down
73 changes: 73 additions & 0 deletions src/commands/compute/logs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import type * as ErrorsModule from '../../lib/errors.js';

const ossFetchMock = vi.hoisted(() => vi.fn());
const outputJsonMock = vi.hoisted(() => vi.fn());
vi.mock('../../lib/api/oss.js', () => ({ ossFetch: ossFetchMock }));
vi.mock('../../lib/credentials.js', () => ({ requireAuth: vi.fn().mockResolvedValue(undefined) }));
vi.mock('../../lib/skills.js', () => ({ reportCliUsage: vi.fn() }));
vi.mock('../../lib/output.js', () => ({ outputJson: outputJsonMock }));
vi.mock('../../lib/errors.js', async (importOriginal) => {
const actual = await importOriginal<typeof ErrorsModule>();
return {
...actual,
handleError: (err: unknown) => { throw err; },
};
});

import { Command } from 'commander';
import { registerComputeLogsCommand, formatLogLine } from './logs.js';

function run(args: string[]) {
const cmd = new Command();
cmd.exitOverride();
cmd.option('--json');
const compute = cmd.command('compute');
registerComputeLogsCommand(compute);
return cmd.parseAsync(['node', 'insforge', ...args]);
}

describe('compute logs', () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
let logSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
ossFetchMock.mockReset();
outputJsonMock.mockReset();
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => logSpy.mockRestore());

it('calls the container logs endpoint with a clamped limit', async () => {
ossFetchMock.mockResolvedValueOnce({ json: async () => ({ lines: [], nextToken: null }) });
await run(['compute', 'logs', 'my api', '--limit', '5000']);
expect(ossFetchMock).toHaveBeenCalledWith('/api/compute/services/my%20api/logs?limit=1000');
expect(logSpy).toHaveBeenCalledWith('No logs found.');
});

it('forwards --next-token as next_token', async () => {
ossFetchMock.mockResolvedValueOnce({ json: async () => ({ lines: [], nextToken: null }) });
await run(['compute', 'logs', 'svc', '--next-token', 'abc']);
expect(ossFetchMock.mock.calls[0][0]).toBe('/api/compute/services/svc/logs?limit=100&next_token=abc');
});

it('prints formatted lines', async () => {
ossFetchMock.mockResolvedValueOnce({
json: async () => ({
lines: [{ timestamp: 0, message: 'hello', region: 'sjc', instance: 'abc123' }],
nextToken: 'tok',
}),
});
await run(['compute', 'logs', 'svc']);
expect(logSpy).toHaveBeenCalledWith('1970-01-01T00:00:00.000Z [sjc abc123] hello');
});

it('emits the full result (with cursor) under --json', async () => {
const payload = { lines: [{ timestamp: 1, message: 'x' }], nextToken: 'tok' };
ossFetchMock.mockResolvedValueOnce({ json: async () => payload });
await run(['--json', 'compute', 'logs', 'svc']);
expect(outputJsonMock).toHaveBeenCalledWith(payload);
});

it('formatLogLine omits the bracket when no region/instance', () => {
expect(formatLogLine({ timestamp: 0, message: 'm' })).toBe('1970-01-01T00:00:00.000Z m');
});
});
105 changes: 105 additions & 0 deletions src/commands/compute/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { Command } from 'commander';
import { ossFetch } from '../../lib/api/oss.js';
import { requireAuth } from '../../lib/credentials.js';
import { handleError, getRootOpts } from '../../lib/errors.js';
import { outputJson } from '../../lib/output.js';
import { reportCliUsage } from '../../lib/skills.js';

// `compute logs <id>` returns container stdout/stderr ("application logs") —
// the same data the dashboard Logs panel shows. For machine lifecycle events
// (start/stop/exit/restart) use `compute events <id>` instead.
//
// Endpoint: GET /api/compute/services/:id/logs?limit=&next_token=
// Response: { lines: { timestamp, message, instance?, region? }[], nextToken: string | null }
// `nextToken` is an opaque forward cursor; `--follow` polls with it. The
// endpoint is rate-limited server-side (dashboard polls every ~2s), so the
// follow interval stays at 2s.

export interface ComputeLogLine {
timestamp: number;
message: string;
instance?: string;
region?: string;
}

export interface ComputeLogsResult {
lines: ComputeLogLine[];
nextToken: string | null;
}

const FOLLOW_INTERVAL_MS = 2000;

export function formatLogLine(line: ComputeLogLine): string {
const ts = new Date(line.timestamp).toISOString();
const where = [line.region, line.instance].filter(Boolean).join(' ');
return where ? `${ts} [${where}] ${line.message}` : `${ts} ${line.message}`;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export async function fetchComputeLogs(
id: string,
opts: { limit: number; nextToken?: string },
): Promise<ComputeLogsResult> {
const params = new URLSearchParams({ limit: String(opts.limit) });
if (opts.nextToken) params.set('next_token', opts.nextToken);
const res = await ossFetch(
`/api/compute/services/${encodeURIComponent(id)}/logs?${params.toString()}`,
);
const body = await res.json() as Partial<ComputeLogsResult> | null;
return {
lines: Array.isArray(body?.lines) ? body.lines : [],
nextToken: typeof body?.nextToken === 'string' && body.nextToken.length > 0 ? body.nextToken : null,
};
}

export function registerComputeLogsCommand(computeCmd: Command): void {
computeCmd
.command('logs <id>')
.description('Get compute service container logs (stdout/stderr)')
.option('--limit <n>', 'Max number of log lines per fetch (1-1000)', '100')
.option('-f, --follow', 'Keep polling for new lines (Ctrl+C to stop)')
.option('--next-token <token>', 'Resume from a cursor returned by a previous --json call')
.action(async (id: string, opts, cmd) => {
const { json } = getRootOpts(cmd);
try {
await requireAuth();

const limit = Math.max(1, Math.min(Number(opts.limit) || 100, 1000));
let result = await fetchComputeLogs(id, { limit, nextToken: opts.nextToken });

if (json && !opts.follow) {
outputJson(result);
await reportCliUsage('cli.compute.logs', true);
return;
}

const print = (lines: ComputeLogLine[]) => {
for (const line of lines) {
console.log(json ? JSON.stringify(line) : formatLogLine(line));
}
Comment on lines +169 to +172

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 JSON follow output breaks

When --json and --follow are combined, this branch writes each log line as a separate JSON object and never emits the page cursor, causing stdout to be neither the documented { lines, nextToken } result nor a single parseable JSON value.

Knowledge Base Used: CLI command runtime

};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (result.lines.length === 0 && !opts.follow) {
console.log('No logs found.');
await reportCliUsage('cli.compute.logs', true);
return;
}
print(result.lines);

if (opts.follow) {
if (!json) console.error('Following logs... (Ctrl+C to stop)');
let token = result.nextToken;
while (true) {
await new Promise((r) => setTimeout(r, FOLLOW_INTERVAL_MS));
result = await fetchComputeLogs(id, { limit, nextToken: token ?? undefined });
print(result.lines);
if (result.nextToken) token = result.nextToken;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

await reportCliUsage('cli.compute.logs', true);
} catch (err) {
await reportCliUsage('cli.compute.logs', false);
handleError(err, json);
}
});
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import { registerComputeDeleteCommand } from './commands/compute/delete.js';
import { registerComputeStartCommand } from './commands/compute/start.js';
import { registerComputeStopCommand } from './commands/compute/stop.js';
import { registerComputeEventsCommand } from './commands/compute/events.js';
import { registerComputeLogsCommand } from './commands/compute/logs.js';
import { registerComputeDeployCommand } from './commands/compute/deploy.js';

import { registerLogsCommand } from './commands/logs.js';
Expand Down Expand Up @@ -276,6 +277,7 @@ registerComputeDeleteCommand(computeCmd);
registerComputeStartCommand(computeCmd);
registerComputeStopCommand(computeCmd);
registerComputeEventsCommand(computeCmd);
registerComputeLogsCommand(computeCmd);

// PostHog commands
const posthogCmd = program.command('posthog').description('Manage PostHog product analytics integration');
Expand Down
Loading