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
5 changes: 5 additions & 0 deletions .changeset/cli-runs-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/cli': patch
---

Add `--since`/`--until` to `workflow inspect runs`; `workflow start` and bulk `workflow cancel` name lookups now search past the backend's default 24h window.
5 changes: 5 additions & 0 deletions .changeset/web-runs-infinite-scroll.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/web': patch
---

Runs list: infinite scroll with SWR-cached pages (tab switches no longer refetch), a plan-aware time-window picker (default 24h), and status filtering without requiring a workflow filter.
6 changes: 6 additions & 0 deletions .changeset/world-analytics-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/world': patch
'@workflow/world-vercel': patch
---

Support `startTime`/`endTime` on `world.analytics.runs.list` to bound the listing window.
37 changes: 29 additions & 8 deletions packages/cli/src/commands/cancel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
} from '../lib/inspect/errors.js';
import { cliFlags } from '../lib/inspect/flags.js';
import { setupCliWorld } from '../lib/inspect/setup.js';
import { planWindowStartFromResponse } from '../lib/inspect/time-window.js';

export default class Cancel extends BaseCommand {
static description =
Expand DownExpand Up@@ -110,18 +111,38 @@ export default class Cancel extends BaseCommand {
// Fetch matching runs. Only metadata is needed to display and cancel, so
// prefer the analytics read path when the backend provides one.
const status = flags.status as WorkflowRun['status'] | undefined;
const runList = world.analytics
? await world.analytics.runs.list({
const limit = flags.limit || 50;
const analytics = world.analytics;
const fetchMatches = async () => {
if (!analytics) {
return world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
})
: await world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
pagination: { limit },
resolveData: 'none',
});
}
// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend), but bulk cancel must match
// across the plan's whole observability window — a run can sleep or
// wait on a hook for days without emitting recent events. Probe for
// the plan window bounds first, then match across them.
const probe = await analytics.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: 1 },
});
const windowStart = planWindowStartFromResponse(probe);
return analytics.runs.list({
status,
workflowName: flags.workflowName,
...(windowStart
? { startTime: windowStart, endTime: new Date().toISOString() }
: {}),
pagination: { limit },
});
};
const runList = await fetchMatches();
const runs = {
data: runList.data.map((run) => ({
runId: run.runId,
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/inspect.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,6 +129,22 @@ export default class Inspect extends BaseCommand {
helpGroup: 'Filtering',
helpLabel: '--status',
}),
since: Flags.string({
description:
'list runs active since a relative duration (30m, 12h, 7d, 2w) or timestamp; defaults to the backend window (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--since',
helpValue: 'DURATION|TIMESTAMP',
}),
until: Flags.string({
description:
'end of the --since listing window, as a relative duration or timestamp; defaults to now (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--until',
helpValue: 'DURATION|TIMESTAMP',
}),
withData: Flags.boolean({
description:
'include full input/output data in list views (deprecated for list views — use `inspect <resource> <id>` to view payloads)',
Expand DownExpand Up@@ -282,6 +298,8 @@ function toInspectOptions(flags: any): InspectCLIOptions {
limit: flags.limit,
workflowName: flags.workflowName,
status: flags.status,
since: flags.since,
until: flags.until,
withData: flags.withData,
decrypt: flags.decrypt,
backend: flags.backend,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/config/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,10 @@ export type InspectCLIOptions = {
limit?: number;
workflowName?: string;
status?: string;
/** Listing window start: relative duration (12h, 7d) or timestamp (runs only) */
since?: string;
/** Listing window end: relative duration or timestamp; requires --since */
until?: string;
withData?: boolean;
backend?: string;
disableRelativeDates?: boolean;
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/lib/inspect/output.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
isEncryptedRef,
isExpiredRef,
} from './hydration.js';
import { resolveTimeWindow } from './time-window.js';

/**
* Create an EncryptionKeyResolver from a World instance.
Expand DownExpand Up@@ -620,6 +621,17 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const resolveData = opts.withData ? 'all' : 'none';
const status = opts.status as WorkflowRun['status'] | undefined;

// Resolve --since/--until into an explicit listing window. Only the
// analytics read path supports one; the runtime storage APIs have no time
// filter. Without the flags the backend applies its default window
// (trailing 24h on the Vercel backend).
const timeWindow = resolveTimeWindow(opts);
if (timeWindow && !useAnalytics) {
logger.warn(
'--since/--until require the analytics read path and are ignored by this backend.'
);
}

// Determine which props to show based on withData flag
const props = opts.withData
? WORKFLOW_RUN_LISTED_PROPS
Expand All@@ -639,6 +651,7 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const runs = await world.analytics.runs.list({
workflowName: opts.workflowName,
status,
...(timeWindow ?? {}),
pagination,
});
return {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/inspect/run.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { start } from '@workflow/core/runtime';
import { healthCheck } from '@workflow/core/runtime/helpers';
import type { World } from '@workflow/world';
import { logger } from '../config/log.js';
import { planWindowStartFromResponse } from './time-window.js';

interface CLICreateOpts {
json?: boolean;
Expand DownExpand Up@@ -57,6 +58,23 @@ export const startRun = async (
resolveData: 'none',
});
run = runList.data[0];

// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend). When the name wasn't found
// there, retry across the plan's whole observability window using the
// window bounds from the response's page metadata.
if (!run && world.analytics) {
const windowStart = planWindowStartFromResponse(runList);
if (windowStart) {
const widened = await world.analytics.runs.list({
workflowName: workflowNameOrRunId,
startTime: windowStart,
endTime: new Date().toISOString(),
pagination: { sortOrder: 'desc', limit: 1 },
});
run = widened.data[0];
}
}
}

if (!run) {
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/lib/inspect/time-window.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
parseTimeInput,
planWindowStartFromResponse,
resolveTimeWindow,
} from './time-window.js';

const NOW = new Date('2026-07-07T12:00:00.000Z');

afterEach(() => {
vi.restoreAllMocks();
});

describe('parseTimeInput', () => {
it('parses relative durations as that long ago', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(parseTimeInput('30m', 'since').toISOString()).toBe(
'2026-07-07T11:30:00.000Z'
);
expect(parseTimeInput('12h', 'since').toISOString()).toBe(
'2026-07-07T00:00:00.000Z'
);
expect(parseTimeInput('7d', 'since').toISOString()).toBe(
'2026-06-30T12:00:00.000Z'
);
expect(parseTimeInput('2w', 'since').toISOString()).toBe(
'2026-06-23T12:00:00.000Z'
);
});

it('parses absolute timestamps', () => {
expect(parseTimeInput('2026-07-01T00:00:00Z', 'since').toISOString()).toBe(
'2026-07-01T00:00:00.000Z'
);
});

it('rejects invalid values with the flag name in the error', () => {
expect(() => parseTimeInput('yesterday-ish', 'since')).toThrow('--since');
});
});

describe('resolveTimeWindow', () => {
it('returns undefined when neither flag is given', () => {
expect(resolveTimeWindow({})).toBeUndefined();
});

it('defaults the end to now when only --since is given', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(resolveTimeWindow({ since: '24h' })).toEqual({
startTime: '2026-07-06T12:00:00.000Z',
endTime: '2026-07-07T12:00:00.000Z',
});
});

it('rejects --until without --since', () => {
expect(() => resolveTimeWindow({ until: '1h' })).toThrow(
'--until requires --since'
);
});

it('rejects inverted windows', () => {
expect(() => resolveTimeWindow({ since: '1h', until: '2h' })).toThrow(
'--since must be earlier than --until'
);
});
});

describe('planWindowStartFromResponse', () => {
it('extracts the plan window start from page metadata', () => {
expect(
planWindowStartFromResponse({
data: [],
pageInfo: {
currentLookbackDays: 30,
maxLookbackDays: 30,
currentWindowStart: '2026-06-07T12:00:00.000Z',
maxWindowStart: '2026-06-07T12:00:00.000Z',
upgradeAvailable: false,
},
})
).toBe('2026-06-07T12:00:00.000Z');
});

it('returns undefined without page metadata', () => {
expect(planWindowStartFromResponse({ data: [] })).toBeUndefined();
expect(planWindowStartFromResponse(null)).toBeUndefined();
});
});
69 changes: 69 additions & 0 deletions packages/cli/src/lib/inspect/time-window.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
import type { AnalyticsPageInfo } from './pagination.js';

const DURATION_UNIT_MS: Record<string, number> = {
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
};

/**
* Parse a `--since`/`--until` value: either a relative duration (`30m`,
* `12h`, `7d`, `2w`, interpreted as "that long ago") or an absolute
* timestamp accepted by `Date`.
*/
export function parseTimeInput(value: string, flagName: string): Date {
const duration = /^(\d+)(m|h|d|w)$/.exec(value.trim());
if (duration) {
const [, amount, unit] = duration;
return new Date(Date.now() - Number(amount) * DURATION_UNIT_MS[unit]);
}
const absolute = new Date(value);
if (!Number.isNaN(absolute.getTime())) {
return absolute;
}
throw new Error(
`Invalid --${flagName} value "${value}". Use a relative duration (30m, 12h, 7d, 2w) or a timestamp (2026-07-01T00:00:00Z).`
);
}

/**
* Resolve `--since`/`--until` flags into an explicit listing window for the
* analytics runs API, or undefined when neither flag was given (the backend
* then applies its default window). Windows older than the plan's
* observability lookback are rejected by the backend with
* `observability-upgrade-required`.
*/
export function resolveTimeWindow(opts: {
since?: string;
until?: string;
}): { startTime: string; endTime: string } | undefined {
if (!opts.since && !opts.until) return undefined;
if (!opts.since) {
throw new Error('--until requires --since.');
}
const start = parseTimeInput(opts.since, 'since');
const end = opts.until
? parseTimeInput(opts.until, 'until')
: new Date(Date.now());
if (start.getTime() >= end.getTime()) {
throw new Error('--since must be earlier than --until.');
}
return { startTime: start.toISOString(), endTime: end.toISOString() };
}

/**
* Extract the plan observability-window start from a paginated analytics
* response, for widening a defaulted (recent-window) listing to the whole
* window the plan allows. Returns undefined when the response carries no
* page metadata (e.g. non-Vercel backends).
*/
export function planWindowStartFromResponse(
result: unknown
): string | undefined {
if (typeof result !== 'object' || result === null) return undefined;
const pageInfo = (result as { pageInfo?: AnalyticsPageInfo }).pageInfo;
if (!pageInfo?.currentWindowStart) return undefined;
const start = new Date(pageInfo.currentWindowStart);
return Number.isNaN(start.getTime()) ? undefined : start.toISOString();
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[world][web][cli] o11y: window-aware runs listing by karthikscale3 · Pull Request #2812 · vercel/workflow · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/cli-runs-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/cli': patch
---

Add `--since`/`--until` to `workflow inspect runs`; `workflow start` and bulk `workflow cancel` name lookups now search past the backend's default 24h window.
5 changes: 5 additions & 0 deletions .changeset/web-runs-infinite-scroll.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/web': patch
---

Runs list: infinite scroll with SWR-cached pages (tab switches no longer refetch), a plan-aware time-window picker (default 24h), and status filtering without requiring a workflow filter.
6 changes: 6 additions & 0 deletions .changeset/world-analytics-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/world': patch
'@workflow/world-vercel': patch
---

Support `startTime`/`endTime` on `world.analytics.runs.list` to bound the listing window.
37 changes: 29 additions & 8 deletions packages/cli/src/commands/cancel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
} from '../lib/inspect/errors.js';
import { cliFlags } from '../lib/inspect/flags.js';
import { setupCliWorld } from '../lib/inspect/setup.js';
import { planWindowStartFromResponse } from '../lib/inspect/time-window.js';

export default class Cancel extends BaseCommand {
static description =
Expand DownExpand Up@@ -110,18 +111,38 @@ export default class Cancel extends BaseCommand {
// Fetch matching runs. Only metadata is needed to display and cancel, so
// prefer the analytics read path when the backend provides one.
const status = flags.status as WorkflowRun['status'] | undefined;
const runList = world.analytics
? await world.analytics.runs.list({
const limit = flags.limit || 50;
const analytics = world.analytics;
const fetchMatches = async () => {
if (!analytics) {
return world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
})
: await world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
pagination: { limit },
resolveData: 'none',
});
}
// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend), but bulk cancel must match
// across the plan's whole observability window — a run can sleep or
// wait on a hook for days without emitting recent events. Probe for
// the plan window bounds first, then match across them.
const probe = await analytics.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: 1 },
});
const windowStart = planWindowStartFromResponse(probe);
return analytics.runs.list({
status,
workflowName: flags.workflowName,
...(windowStart
? { startTime: windowStart, endTime: new Date().toISOString() }
: {}),
pagination: { limit },
});
};
const runList = await fetchMatches();
const runs = {
data: runList.data.map((run) => ({
runId: run.runId,
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/inspect.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,6 +129,22 @@ export default class Inspect extends BaseCommand {
helpGroup: 'Filtering',
helpLabel: '--status',
}),
since: Flags.string({
description:
'list runs active since a relative duration (30m, 12h, 7d, 2w) or timestamp; defaults to the backend window (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--since',
helpValue: 'DURATION|TIMESTAMP',
}),
until: Flags.string({
description:
'end of the --since listing window, as a relative duration or timestamp; defaults to now (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--until',
helpValue: 'DURATION|TIMESTAMP',
}),
withData: Flags.boolean({
description:
'include full input/output data in list views (deprecated for list views — use `inspect <resource> <id>` to view payloads)',
Expand DownExpand Up@@ -282,6 +298,8 @@ function toInspectOptions(flags: any): InspectCLIOptions {
limit: flags.limit,
workflowName: flags.workflowName,
status: flags.status,
since: flags.since,
until: flags.until,
withData: flags.withData,
decrypt: flags.decrypt,
backend: flags.backend,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/config/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,10 @@ export type InspectCLIOptions = {
limit?: number;
workflowName?: string;
status?: string;
/** Listing window start: relative duration (12h, 7d) or timestamp (runs only) */
since?: string;
/** Listing window end: relative duration or timestamp; requires --since */
until?: string;
withData?: boolean;
backend?: string;
disableRelativeDates?: boolean;
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/lib/inspect/output.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
isEncryptedRef,
isExpiredRef,
} from './hydration.js';
import { resolveTimeWindow } from './time-window.js';

/**
* Create an EncryptionKeyResolver from a World instance.
Expand DownExpand Up@@ -620,6 +621,17 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const resolveData = opts.withData ? 'all' : 'none';
const status = opts.status as WorkflowRun['status'] | undefined;

// Resolve --since/--until into an explicit listing window. Only the
// analytics read path supports one; the runtime storage APIs have no time
// filter. Without the flags the backend applies its default window
// (trailing 24h on the Vercel backend).
const timeWindow = resolveTimeWindow(opts);
if (timeWindow && !useAnalytics) {
logger.warn(
'--since/--until require the analytics read path and are ignored by this backend.'
);
}

// Determine which props to show based on withData flag
const props = opts.withData
? WORKFLOW_RUN_LISTED_PROPS
Expand All@@ -639,6 +651,7 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const runs = await world.analytics.runs.list({
workflowName: opts.workflowName,
status,
...(timeWindow ?? {}),
pagination,
});
return {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/inspect/run.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { start } from '@workflow/core/runtime';
import { healthCheck } from '@workflow/core/runtime/helpers';
import type { World } from '@workflow/world';
import { logger } from '../config/log.js';
import { planWindowStartFromResponse } from './time-window.js';

interface CLICreateOpts {
json?: boolean;
Expand DownExpand Up@@ -57,6 +58,23 @@ export const startRun = async (
resolveData: 'none',
});
run = runList.data[0];

// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend). When the name wasn't found
// there, retry across the plan's whole observability window using the
// window bounds from the response's page metadata.
if (!run && world.analytics) {
const windowStart = planWindowStartFromResponse(runList);
if (windowStart) {
const widened = await world.analytics.runs.list({
workflowName: workflowNameOrRunId,
startTime: windowStart,
endTime: new Date().toISOString(),
pagination: { sortOrder: 'desc', limit: 1 },
});
run = widened.data[0];
}
}
}

if (!run) {
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/lib/inspect/time-window.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
parseTimeInput,
planWindowStartFromResponse,
resolveTimeWindow,
} from './time-window.js';

const NOW = new Date('2026-07-07T12:00:00.000Z');

afterEach(() => {
vi.restoreAllMocks();
});

describe('parseTimeInput', () => {
it('parses relative durations as that long ago', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(parseTimeInput('30m', 'since').toISOString()).toBe(
'2026-07-07T11:30:00.000Z'
);
expect(parseTimeInput('12h', 'since').toISOString()).toBe(
'2026-07-07T00:00:00.000Z'
);
expect(parseTimeInput('7d', 'since').toISOString()).toBe(
'2026-06-30T12:00:00.000Z'
);
expect(parseTimeInput('2w', 'since').toISOString()).toBe(
'2026-06-23T12:00:00.000Z'
);
});

it('parses absolute timestamps', () => {
expect(parseTimeInput('2026-07-01T00:00:00Z', 'since').toISOString()).toBe(
'2026-07-01T00:00:00.000Z'
);
});

it('rejects invalid values with the flag name in the error', () => {
expect(() => parseTimeInput('yesterday-ish', 'since')).toThrow('--since');
});
});

describe('resolveTimeWindow', () => {
it('returns undefined when neither flag is given', () => {
expect(resolveTimeWindow({})).toBeUndefined();
});

it('defaults the end to now when only --since is given', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(resolveTimeWindow({ since: '24h' })).toEqual({
startTime: '2026-07-06T12:00:00.000Z',
endTime: '2026-07-07T12:00:00.000Z',
});
});

it('rejects --until without --since', () => {
expect(() => resolveTimeWindow({ until: '1h' })).toThrow(
'--until requires --since'
);
});

it('rejects inverted windows', () => {
expect(() => resolveTimeWindow({ since: '1h', until: '2h' })).toThrow(
'--since must be earlier than --until'
);
});
});

describe('planWindowStartFromResponse', () => {
it('extracts the plan window start from page metadata', () => {
expect(
planWindowStartFromResponse({
data: [],
pageInfo: {
currentLookbackDays: 30,
maxLookbackDays: 30,
currentWindowStart: '2026-06-07T12:00:00.000Z',
maxWindowStart: '2026-06-07T12:00:00.000Z',
upgradeAvailable: false,
},
})
).toBe('2026-06-07T12:00:00.000Z');
});

it('returns undefined without page metadata', () => {
expect(planWindowStartFromResponse({ data: [] })).toBeUndefined();
expect(planWindowStartFromResponse(null)).toBeUndefined();
});
});
69 changes: 69 additions & 0 deletions packages/cli/src/lib/inspect/time-window.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
import type { AnalyticsPageInfo } from './pagination.js';

const DURATION_UNIT_MS: Record<string, number> = {
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
};

/**
* Parse a `--since`/`--until` value: either a relative duration (`30m`,
* `12h`, `7d`, `2w`, interpreted as "that long ago") or an absolute
* timestamp accepted by `Date`.
*/
export function parseTimeInput(value: string, flagName: string): Date {
const duration = /^(\d+)(m|h|d|w)$/.exec(value.trim());
if (duration) {
const [, amount, unit] = duration;
return new Date(Date.now() - Number(amount) * DURATION_UNIT_MS[unit]);
}
const absolute = new Date(value);
if (!Number.isNaN(absolute.getTime())) {
return absolute;
}
throw new Error(
`Invalid --${flagName} value "${value}". Use a relative duration (30m, 12h, 7d, 2w) or a timestamp (2026-07-01T00:00:00Z).`
);
}

/**
* Resolve `--since`/`--until` flags into an explicit listing window for the
* analytics runs API, or undefined when neither flag was given (the backend
* then applies its default window). Windows older than the plan's
* observability lookback are rejected by the backend with
* `observability-upgrade-required`.
*/
export function resolveTimeWindow(opts: {
since?: string;
until?: string;
}): { startTime: string; endTime: string } | undefined {
if (!opts.since && !opts.until) return undefined;
if (!opts.since) {
throw new Error('--until requires --since.');
}
const start = parseTimeInput(opts.since, 'since');
const end = opts.until
? parseTimeInput(opts.until, 'until')
: new Date(Date.now());
if (start.getTime() >= end.getTime()) {
throw new Error('--since must be earlier than --until.');
}
return { startTime: start.toISOString(), endTime: end.toISOString() };
}

/**
* Extract the plan observability-window start from a paginated analytics
* response, for widening a defaulted (recent-window) listing to the whole
* window the plan allows. Returns undefined when the response carries no
* page metadata (e.g. non-Vercel backends).
*/
export function planWindowStartFromResponse(
result: unknown
): string | undefined {
if (typeof result !== 'object' || result === null) return undefined;
const pageInfo = (result as { pageInfo?: AnalyticsPageInfo }).pageInfo;
if (!pageInfo?.currentWindowStart) return undefined;
const start = new Date(pageInfo.currentWindowStart);
return Number.isNaN(start.getTime()) ? undefined : start.toISOString();
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [world][web][cli] o11y: window-aware runs listing by karthikscale3 · Pull Request #2812 · vercel/workflow · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/cli-runs-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/cli': patch
---

Add `--since`/`--until` to `workflow inspect runs`; `workflow start` and bulk `workflow cancel` name lookups now search past the backend's default 24h window.
5 changes: 5 additions & 0 deletions .changeset/web-runs-infinite-scroll.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/web': patch
---

Runs list: infinite scroll with SWR-cached pages (tab switches no longer refetch), a plan-aware time-window picker (default 24h), and status filtering without requiring a workflow filter.
6 changes: 6 additions & 0 deletions .changeset/world-analytics-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/world': patch
'@workflow/world-vercel': patch
---

Support `startTime`/`endTime` on `world.analytics.runs.list` to bound the listing window.
37 changes: 29 additions & 8 deletions packages/cli/src/commands/cancel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
} from '../lib/inspect/errors.js';
import { cliFlags } from '../lib/inspect/flags.js';
import { setupCliWorld } from '../lib/inspect/setup.js';
import { planWindowStartFromResponse } from '../lib/inspect/time-window.js';

export default class Cancel extends BaseCommand {
static description =
Expand DownExpand Up@@ -110,18 +111,38 @@ export default class Cancel extends BaseCommand {
// Fetch matching runs. Only metadata is needed to display and cancel, so
// prefer the analytics read path when the backend provides one.
const status = flags.status as WorkflowRun['status'] | undefined;
const runList = world.analytics
? await world.analytics.runs.list({
const limit = flags.limit || 50;
const analytics = world.analytics;
const fetchMatches = async () => {
if (!analytics) {
return world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
})
: await world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
pagination: { limit },
resolveData: 'none',
});
}
// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend), but bulk cancel must match
// across the plan's whole observability window — a run can sleep or
// wait on a hook for days without emitting recent events. Probe for
// the plan window bounds first, then match across them.
const probe = await analytics.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: 1 },
});
const windowStart = planWindowStartFromResponse(probe);
return analytics.runs.list({
status,
workflowName: flags.workflowName,
...(windowStart
? { startTime: windowStart, endTime: new Date().toISOString() }
: {}),
pagination: { limit },
});
};
const runList = await fetchMatches();
const runs = {
data: runList.data.map((run) => ({
runId: run.runId,
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/inspect.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,6 +129,22 @@ export default class Inspect extends BaseCommand {
helpGroup: 'Filtering',
helpLabel: '--status',
}),
since: Flags.string({
description:
'list runs active since a relative duration (30m, 12h, 7d, 2w) or timestamp; defaults to the backend window (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--since',
helpValue: 'DURATION|TIMESTAMP',
}),
until: Flags.string({
description:
'end of the --since listing window, as a relative duration or timestamp; defaults to now (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--until',
helpValue: 'DURATION|TIMESTAMP',
}),
withData: Flags.boolean({
description:
'include full input/output data in list views (deprecated for list views — use `inspect <resource> <id>` to view payloads)',
Expand DownExpand Up@@ -282,6 +298,8 @@ function toInspectOptions(flags: any): InspectCLIOptions {
limit: flags.limit,
workflowName: flags.workflowName,
status: flags.status,
since: flags.since,
until: flags.until,
withData: flags.withData,
decrypt: flags.decrypt,
backend: flags.backend,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/config/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,10 @@ export type InspectCLIOptions = {
limit?: number;
workflowName?: string;
status?: string;
/** Listing window start: relative duration (12h, 7d) or timestamp (runs only) */
since?: string;
/** Listing window end: relative duration or timestamp; requires --since */
until?: string;
withData?: boolean;
backend?: string;
disableRelativeDates?: boolean;
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/lib/inspect/output.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
isEncryptedRef,
isExpiredRef,
} from './hydration.js';
import { resolveTimeWindow } from './time-window.js';

/**
* Create an EncryptionKeyResolver from a World instance.
Expand DownExpand Up@@ -620,6 +621,17 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const resolveData = opts.withData ? 'all' : 'none';
const status = opts.status as WorkflowRun['status'] | undefined;

// Resolve --since/--until into an explicit listing window. Only the
// analytics read path supports one; the runtime storage APIs have no time
// filter. Without the flags the backend applies its default window
// (trailing 24h on the Vercel backend).
const timeWindow = resolveTimeWindow(opts);
if (timeWindow && !useAnalytics) {
logger.warn(
'--since/--until require the analytics read path and are ignored by this backend.'
);
}

// Determine which props to show based on withData flag
const props = opts.withData
? WORKFLOW_RUN_LISTED_PROPS
Expand All@@ -639,6 +651,7 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const runs = await world.analytics.runs.list({
workflowName: opts.workflowName,
status,
...(timeWindow ?? {}),
pagination,
});
return {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/inspect/run.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { start } from '@workflow/core/runtime';
import { healthCheck } from '@workflow/core/runtime/helpers';
import type { World } from '@workflow/world';
import { logger } from '../config/log.js';
import { planWindowStartFromResponse } from './time-window.js';

interface CLICreateOpts {
json?: boolean;
Expand DownExpand Up@@ -57,6 +58,23 @@ export const startRun = async (
resolveData: 'none',
});
run = runList.data[0];

// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend). When the name wasn't found
// there, retry across the plan's whole observability window using the
// window bounds from the response's page metadata.
if (!run && world.analytics) {
const windowStart = planWindowStartFromResponse(runList);
if (windowStart) {
const widened = await world.analytics.runs.list({
workflowName: workflowNameOrRunId,
startTime: windowStart,
endTime: new Date().toISOString(),
pagination: { sortOrder: 'desc', limit: 1 },
});
run = widened.data[0];
}
}
}

if (!run) {
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/lib/inspect/time-window.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
parseTimeInput,
planWindowStartFromResponse,
resolveTimeWindow,
} from './time-window.js';

const NOW = new Date('2026-07-07T12:00:00.000Z');

afterEach(() => {
vi.restoreAllMocks();
});

describe('parseTimeInput', () => {
it('parses relative durations as that long ago', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(parseTimeInput('30m', 'since').toISOString()).toBe(
'2026-07-07T11:30:00.000Z'
);
expect(parseTimeInput('12h', 'since').toISOString()).toBe(
'2026-07-07T00:00:00.000Z'
);
expect(parseTimeInput('7d', 'since').toISOString()).toBe(
'2026-06-30T12:00:00.000Z'
);
expect(parseTimeInput('2w', 'since').toISOString()).toBe(
'2026-06-23T12:00:00.000Z'
);
});

it('parses absolute timestamps', () => {
expect(parseTimeInput('2026-07-01T00:00:00Z', 'since').toISOString()).toBe(
'2026-07-01T00:00:00.000Z'
);
});

it('rejects invalid values with the flag name in the error', () => {
expect(() => parseTimeInput('yesterday-ish', 'since')).toThrow('--since');
});
});

describe('resolveTimeWindow', () => {
it('returns undefined when neither flag is given', () => {
expect(resolveTimeWindow({})).toBeUndefined();
});

it('defaults the end to now when only --since is given', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(resolveTimeWindow({ since: '24h' })).toEqual({
startTime: '2026-07-06T12:00:00.000Z',
endTime: '2026-07-07T12:00:00.000Z',
});
});

it('rejects --until without --since', () => {
expect(() => resolveTimeWindow({ until: '1h' })).toThrow(
'--until requires --since'
);
});

it('rejects inverted windows', () => {
expect(() => resolveTimeWindow({ since: '1h', until: '2h' })).toThrow(
'--since must be earlier than --until'
);
});
});

describe('planWindowStartFromResponse', () => {
it('extracts the plan window start from page metadata', () => {
expect(
planWindowStartFromResponse({
data: [],
pageInfo: {
currentLookbackDays: 30,
maxLookbackDays: 30,
currentWindowStart: '2026-06-07T12:00:00.000Z',
maxWindowStart: '2026-06-07T12:00:00.000Z',
upgradeAvailable: false,
},
})
).toBe('2026-06-07T12:00:00.000Z');
});

it('returns undefined without page metadata', () => {
expect(planWindowStartFromResponse({ data: [] })).toBeUndefined();
expect(planWindowStartFromResponse(null)).toBeUndefined();
});
});
69 changes: 69 additions & 0 deletions packages/cli/src/lib/inspect/time-window.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
import type { AnalyticsPageInfo } from './pagination.js';

const DURATION_UNIT_MS: Record<string, number> = {
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
};

/**
* Parse a `--since`/`--until` value: either a relative duration (`30m`,
* `12h`, `7d`, `2w`, interpreted as "that long ago") or an absolute
* timestamp accepted by `Date`.
*/
export function parseTimeInput(value: string, flagName: string): Date {
const duration = /^(\d+)(m|h|d|w)$/.exec(value.trim());
if (duration) {
const [, amount, unit] = duration;
return new Date(Date.now() - Number(amount) * DURATION_UNIT_MS[unit]);
}
const absolute = new Date(value);
if (!Number.isNaN(absolute.getTime())) {
return absolute;
}
throw new Error(
`Invalid --${flagName} value "${value}". Use a relative duration (30m, 12h, 7d, 2w) or a timestamp (2026-07-01T00:00:00Z).`
);
}

/**
* Resolve `--since`/`--until` flags into an explicit listing window for the
* analytics runs API, or undefined when neither flag was given (the backend
* then applies its default window). Windows older than the plan's
* observability lookback are rejected by the backend with
* `observability-upgrade-required`.
*/
export function resolveTimeWindow(opts: {
since?: string;
until?: string;
}): { startTime: string; endTime: string } | undefined {
if (!opts.since && !opts.until) return undefined;
if (!opts.since) {
throw new Error('--until requires --since.');
}
const start = parseTimeInput(opts.since, 'since');
const end = opts.until
? parseTimeInput(opts.until, 'until')
: new Date(Date.now());
if (start.getTime() >= end.getTime()) {
throw new Error('--since must be earlier than --until.');
}
return { startTime: start.toISOString(), endTime: end.toISOString() };
}

/**
* Extract the plan observability-window start from a paginated analytics
* response, for widening a defaulted (recent-window) listing to the whole
* window the plan allows. Returns undefined when the response carries no
* page metadata (e.g. non-Vercel backends).
*/
export function planWindowStartFromResponse(
result: unknown
): string | undefined {
if (typeof result !== 'object' || result === null) return undefined;
const pageInfo = (result as { pageInfo?: AnalyticsPageInfo }).pageInfo;
if (!pageInfo?.currentWindowStart) return undefined;
const start = new Date(pageInfo.currentWindowStart);
return Number.isNaN(start.getTime()) ? undefined : start.toISOString();
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [world][web][cli] o11y: window-aware runs listing by karthikscale3 · Pull Request #2812 · vercel/workflow · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/cli-runs-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/cli': patch
---

Add `--since`/`--until` to `workflow inspect runs`; `workflow start` and bulk `workflow cancel` name lookups now search past the backend's default 24h window.
5 changes: 5 additions & 0 deletions .changeset/web-runs-infinite-scroll.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/web': patch
---

Runs list: infinite scroll with SWR-cached pages (tab switches no longer refetch), a plan-aware time-window picker (default 24h), and status filtering without requiring a workflow filter.
6 changes: 6 additions & 0 deletions .changeset/world-analytics-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/world': patch
'@workflow/world-vercel': patch
---

Support `startTime`/`endTime` on `world.analytics.runs.list` to bound the listing window.
37 changes: 29 additions & 8 deletions packages/cli/src/commands/cancel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
} from '../lib/inspect/errors.js';
import { cliFlags } from '../lib/inspect/flags.js';
import { setupCliWorld } from '../lib/inspect/setup.js';
import { planWindowStartFromResponse } from '../lib/inspect/time-window.js';

export default class Cancel extends BaseCommand {
static description =
Expand DownExpand Up@@ -110,18 +111,38 @@ export default class Cancel extends BaseCommand {
// Fetch matching runs. Only metadata is needed to display and cancel, so
// prefer the analytics read path when the backend provides one.
const status = flags.status as WorkflowRun['status'] | undefined;
const runList = world.analytics
? await world.analytics.runs.list({
const limit = flags.limit || 50;
const analytics = world.analytics;
const fetchMatches = async () => {
if (!analytics) {
return world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
})
: await world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
pagination: { limit },
resolveData: 'none',
});
}
// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend), but bulk cancel must match
// across the plan's whole observability window — a run can sleep or
// wait on a hook for days without emitting recent events. Probe for
// the plan window bounds first, then match across them.
const probe = await analytics.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: 1 },
});
const windowStart = planWindowStartFromResponse(probe);
return analytics.runs.list({
status,
workflowName: flags.workflowName,
...(windowStart
? { startTime: windowStart, endTime: new Date().toISOString() }
: {}),
pagination: { limit },
});
};
const runList = await fetchMatches();
const runs = {
data: runList.data.map((run) => ({
runId: run.runId,
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/inspect.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,6 +129,22 @@ export default class Inspect extends BaseCommand {
helpGroup: 'Filtering',
helpLabel: '--status',
}),
since: Flags.string({
description:
'list runs active since a relative duration (30m, 12h, 7d, 2w) or timestamp; defaults to the backend window (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--since',
helpValue: 'DURATION|TIMESTAMP',
}),
until: Flags.string({
description:
'end of the --since listing window, as a relative duration or timestamp; defaults to now (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--until',
helpValue: 'DURATION|TIMESTAMP',
}),
withData: Flags.boolean({
description:
'include full input/output data in list views (deprecated for list views — use `inspect <resource> <id>` to view payloads)',
Expand DownExpand Up@@ -282,6 +298,8 @@ function toInspectOptions(flags: any): InspectCLIOptions {
limit: flags.limit,
workflowName: flags.workflowName,
status: flags.status,
since: flags.since,
until: flags.until,
withData: flags.withData,
decrypt: flags.decrypt,
backend: flags.backend,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/config/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,10 @@ export type InspectCLIOptions = {
limit?: number;
workflowName?: string;
status?: string;
/** Listing window start: relative duration (12h, 7d) or timestamp (runs only) */
since?: string;
/** Listing window end: relative duration or timestamp; requires --since */
until?: string;
withData?: boolean;
backend?: string;
disableRelativeDates?: boolean;
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/lib/inspect/output.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
isEncryptedRef,
isExpiredRef,
} from './hydration.js';
import { resolveTimeWindow } from './time-window.js';

/**
* Create an EncryptionKeyResolver from a World instance.
Expand DownExpand Up@@ -620,6 +621,17 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const resolveData = opts.withData ? 'all' : 'none';
const status = opts.status as WorkflowRun['status'] | undefined;

// Resolve --since/--until into an explicit listing window. Only the
// analytics read path supports one; the runtime storage APIs have no time
// filter. Without the flags the backend applies its default window
// (trailing 24h on the Vercel backend).
const timeWindow = resolveTimeWindow(opts);
if (timeWindow && !useAnalytics) {
logger.warn(
'--since/--until require the analytics read path and are ignored by this backend.'
);
}

// Determine which props to show based on withData flag
const props = opts.withData
? WORKFLOW_RUN_LISTED_PROPS
Expand All@@ -639,6 +651,7 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const runs = await world.analytics.runs.list({
workflowName: opts.workflowName,
status,
...(timeWindow ?? {}),
pagination,
});
return {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/inspect/run.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { start } from '@workflow/core/runtime';
import { healthCheck } from '@workflow/core/runtime/helpers';
import type { World } from '@workflow/world';
import { logger } from '../config/log.js';
import { planWindowStartFromResponse } from './time-window.js';

interface CLICreateOpts {
json?: boolean;
Expand DownExpand Up@@ -57,6 +58,23 @@ export const startRun = async (
resolveData: 'none',
});
run = runList.data[0];

// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend). When the name wasn't found
// there, retry across the plan's whole observability window using the
// window bounds from the response's page metadata.
if (!run && world.analytics) {
const windowStart = planWindowStartFromResponse(runList);
if (windowStart) {
const widened = await world.analytics.runs.list({
workflowName: workflowNameOrRunId,
startTime: windowStart,
endTime: new Date().toISOString(),
pagination: { sortOrder: 'desc', limit: 1 },
});
run = widened.data[0];
}
}
}

if (!run) {
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/lib/inspect/time-window.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
parseTimeInput,
planWindowStartFromResponse,
resolveTimeWindow,
} from './time-window.js';

const NOW = new Date('2026-07-07T12:00:00.000Z');

afterEach(() => {
vi.restoreAllMocks();
});

describe('parseTimeInput', () => {
it('parses relative durations as that long ago', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(parseTimeInput('30m', 'since').toISOString()).toBe(
'2026-07-07T11:30:00.000Z'
);
expect(parseTimeInput('12h', 'since').toISOString()).toBe(
'2026-07-07T00:00:00.000Z'
);
expect(parseTimeInput('7d', 'since').toISOString()).toBe(
'2026-06-30T12:00:00.000Z'
);
expect(parseTimeInput('2w', 'since').toISOString()).toBe(
'2026-06-23T12:00:00.000Z'
);
});

it('parses absolute timestamps', () => {
expect(parseTimeInput('2026-07-01T00:00:00Z', 'since').toISOString()).toBe(
'2026-07-01T00:00:00.000Z'
);
});

it('rejects invalid values with the flag name in the error', () => {
expect(() => parseTimeInput('yesterday-ish', 'since')).toThrow('--since');
});
});

describe('resolveTimeWindow', () => {
it('returns undefined when neither flag is given', () => {
expect(resolveTimeWindow({})).toBeUndefined();
});

it('defaults the end to now when only --since is given', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(resolveTimeWindow({ since: '24h' })).toEqual({
startTime: '2026-07-06T12:00:00.000Z',
endTime: '2026-07-07T12:00:00.000Z',
});
});

it('rejects --until without --since', () => {
expect(() => resolveTimeWindow({ until: '1h' })).toThrow(
'--until requires --since'
);
});

it('rejects inverted windows', () => {
expect(() => resolveTimeWindow({ since: '1h', until: '2h' })).toThrow(
'--since must be earlier than --until'
);
});
});

describe('planWindowStartFromResponse', () => {
it('extracts the plan window start from page metadata', () => {
expect(
planWindowStartFromResponse({
data: [],
pageInfo: {
currentLookbackDays: 30,
maxLookbackDays: 30,
currentWindowStart: '2026-06-07T12:00:00.000Z',
maxWindowStart: '2026-06-07T12:00:00.000Z',
upgradeAvailable: false,
},
})
).toBe('2026-06-07T12:00:00.000Z');
});

it('returns undefined without page metadata', () => {
expect(planWindowStartFromResponse({ data: [] })).toBeUndefined();
expect(planWindowStartFromResponse(null)).toBeUndefined();
});
});
69 changes: 69 additions & 0 deletions packages/cli/src/lib/inspect/time-window.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
import type { AnalyticsPageInfo } from './pagination.js';

const DURATION_UNIT_MS: Record<string, number> = {
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
};

/**
* Parse a `--since`/`--until` value: either a relative duration (`30m`,
* `12h`, `7d`, `2w`, interpreted as "that long ago") or an absolute
* timestamp accepted by `Date`.
*/
export function parseTimeInput(value: string, flagName: string): Date {
const duration = /^(\d+)(m|h|d|w)$/.exec(value.trim());
if (duration) {
const [, amount, unit] = duration;
return new Date(Date.now() - Number(amount) * DURATION_UNIT_MS[unit]);
}
const absolute = new Date(value);
if (!Number.isNaN(absolute.getTime())) {
return absolute;
}
throw new Error(
`Invalid --${flagName} value "${value}". Use a relative duration (30m, 12h, 7d, 2w) or a timestamp (2026-07-01T00:00:00Z).`
);
}

/**
* Resolve `--since`/`--until` flags into an explicit listing window for the
* analytics runs API, or undefined when neither flag was given (the backend
* then applies its default window). Windows older than the plan's
* observability lookback are rejected by the backend with
* `observability-upgrade-required`.
*/
export function resolveTimeWindow(opts: {
since?: string;
until?: string;
}): { startTime: string; endTime: string } | undefined {
if (!opts.since && !opts.until) return undefined;
if (!opts.since) {
throw new Error('--until requires --since.');
}
const start = parseTimeInput(opts.since, 'since');
const end = opts.until
? parseTimeInput(opts.until, 'until')
: new Date(Date.now());
if (start.getTime() >= end.getTime()) {
throw new Error('--since must be earlier than --until.');
}
return { startTime: start.toISOString(), endTime: end.toISOString() };
}

/**
* Extract the plan observability-window start from a paginated analytics
* response, for widening a defaulted (recent-window) listing to the whole
* window the plan allows. Returns undefined when the response carries no
* page metadata (e.g. non-Vercel backends).
*/
export function planWindowStartFromResponse(
result: unknown
): string | undefined {
if (typeof result !== 'object' || result === null) return undefined;
const pageInfo = (result as { pageInfo?: AnalyticsPageInfo }).pageInfo;
if (!pageInfo?.currentWindowStart) return undefined;
const start = new Date(pageInfo.currentWindowStart);
return Number.isNaN(start.getTime()) ? undefined : start.toISOString();
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [world][web][cli] o11y: window-aware runs listing by karthikscale3 · Pull Request #2812 · vercel/workflow · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/cli-runs-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/cli': patch
---

Add `--since`/`--until` to `workflow inspect runs`; `workflow start` and bulk `workflow cancel` name lookups now search past the backend's default 24h window.
5 changes: 5 additions & 0 deletions .changeset/web-runs-infinite-scroll.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/web': patch
---

Runs list: infinite scroll with SWR-cached pages (tab switches no longer refetch), a plan-aware time-window picker (default 24h), and status filtering without requiring a workflow filter.
6 changes: 6 additions & 0 deletions .changeset/world-analytics-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/world': patch
'@workflow/world-vercel': patch
---

Support `startTime`/`endTime` on `world.analytics.runs.list` to bound the listing window.
37 changes: 29 additions & 8 deletions packages/cli/src/commands/cancel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
} from '../lib/inspect/errors.js';
import { cliFlags } from '../lib/inspect/flags.js';
import { setupCliWorld } from '../lib/inspect/setup.js';
import { planWindowStartFromResponse } from '../lib/inspect/time-window.js';

export default class Cancel extends BaseCommand {
static description =
Expand DownExpand Up@@ -110,18 +111,38 @@ export default class Cancel extends BaseCommand {
// Fetch matching runs. Only metadata is needed to display and cancel, so
// prefer the analytics read path when the backend provides one.
const status = flags.status as WorkflowRun['status'] | undefined;
const runList = world.analytics
? await world.analytics.runs.list({
const limit = flags.limit || 50;
const analytics = world.analytics;
const fetchMatches = async () => {
if (!analytics) {
return world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
})
: await world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
pagination: { limit },
resolveData: 'none',
});
}
// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend), but bulk cancel must match
// across the plan's whole observability window — a run can sleep or
// wait on a hook for days without emitting recent events. Probe for
// the plan window bounds first, then match across them.
const probe = await analytics.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: 1 },
});
const windowStart = planWindowStartFromResponse(probe);
return analytics.runs.list({
status,
workflowName: flags.workflowName,
...(windowStart
? { startTime: windowStart, endTime: new Date().toISOString() }
: {}),
pagination: { limit },
});
};
const runList = await fetchMatches();
const runs = {
data: runList.data.map((run) => ({
runId: run.runId,
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/inspect.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,6 +129,22 @@ export default class Inspect extends BaseCommand {
helpGroup: 'Filtering',
helpLabel: '--status',
}),
since: Flags.string({
description:
'list runs active since a relative duration (30m, 12h, 7d, 2w) or timestamp; defaults to the backend window (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--since',
helpValue: 'DURATION|TIMESTAMP',
}),
until: Flags.string({
description:
'end of the --since listing window, as a relative duration or timestamp; defaults to now (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--until',
helpValue: 'DURATION|TIMESTAMP',
}),
withData: Flags.boolean({
description:
'include full input/output data in list views (deprecated for list views — use `inspect <resource> <id>` to view payloads)',
Expand DownExpand Up@@ -282,6 +298,8 @@ function toInspectOptions(flags: any): InspectCLIOptions {
limit: flags.limit,
workflowName: flags.workflowName,
status: flags.status,
since: flags.since,
until: flags.until,
withData: flags.withData,
decrypt: flags.decrypt,
backend: flags.backend,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/config/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,10 @@ export type InspectCLIOptions = {
limit?: number;
workflowName?: string;
status?: string;
/** Listing window start: relative duration (12h, 7d) or timestamp (runs only) */
since?: string;
/** Listing window end: relative duration or timestamp; requires --since */
until?: string;
withData?: boolean;
backend?: string;
disableRelativeDates?: boolean;
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/lib/inspect/output.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
isEncryptedRef,
isExpiredRef,
} from './hydration.js';
import { resolveTimeWindow } from './time-window.js';

/**
* Create an EncryptionKeyResolver from a World instance.
Expand DownExpand Up@@ -620,6 +621,17 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const resolveData = opts.withData ? 'all' : 'none';
const status = opts.status as WorkflowRun['status'] | undefined;

// Resolve --since/--until into an explicit listing window. Only the
// analytics read path supports one; the runtime storage APIs have no time
// filter. Without the flags the backend applies its default window
// (trailing 24h on the Vercel backend).
const timeWindow = resolveTimeWindow(opts);
if (timeWindow && !useAnalytics) {
logger.warn(
'--since/--until require the analytics read path and are ignored by this backend.'
);
}

// Determine which props to show based on withData flag
const props = opts.withData
? WORKFLOW_RUN_LISTED_PROPS
Expand All@@ -639,6 +651,7 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const runs = await world.analytics.runs.list({
workflowName: opts.workflowName,
status,
...(timeWindow ?? {}),
pagination,
});
return {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/inspect/run.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { start } from '@workflow/core/runtime';
import { healthCheck } from '@workflow/core/runtime/helpers';
import type { World } from '@workflow/world';
import { logger } from '../config/log.js';
import { planWindowStartFromResponse } from './time-window.js';

interface CLICreateOpts {
json?: boolean;
Expand DownExpand Up@@ -57,6 +58,23 @@ export const startRun = async (
resolveData: 'none',
});
run = runList.data[0];

// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend). When the name wasn't found
// there, retry across the plan's whole observability window using the
// window bounds from the response's page metadata.
if (!run && world.analytics) {
const windowStart = planWindowStartFromResponse(runList);
if (windowStart) {
const widened = await world.analytics.runs.list({
workflowName: workflowNameOrRunId,
startTime: windowStart,
endTime: new Date().toISOString(),
pagination: { sortOrder: 'desc', limit: 1 },
});
run = widened.data[0];
}
}
}

if (!run) {
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/lib/inspect/time-window.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
parseTimeInput,
planWindowStartFromResponse,
resolveTimeWindow,
} from './time-window.js';

const NOW = new Date('2026-07-07T12:00:00.000Z');

afterEach(() => {
vi.restoreAllMocks();
});

describe('parseTimeInput', () => {
it('parses relative durations as that long ago', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(parseTimeInput('30m', 'since').toISOString()).toBe(
'2026-07-07T11:30:00.000Z'
);
expect(parseTimeInput('12h', 'since').toISOString()).toBe(
'2026-07-07T00:00:00.000Z'
);
expect(parseTimeInput('7d', 'since').toISOString()).toBe(
'2026-06-30T12:00:00.000Z'
);
expect(parseTimeInput('2w', 'since').toISOString()).toBe(
'2026-06-23T12:00:00.000Z'
);
});

it('parses absolute timestamps', () => {
expect(parseTimeInput('2026-07-01T00:00:00Z', 'since').toISOString()).toBe(
'2026-07-01T00:00:00.000Z'
);
});

it('rejects invalid values with the flag name in the error', () => {
expect(() => parseTimeInput('yesterday-ish', 'since')).toThrow('--since');
});
});

describe('resolveTimeWindow', () => {
it('returns undefined when neither flag is given', () => {
expect(resolveTimeWindow({})).toBeUndefined();
});

it('defaults the end to now when only --since is given', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(resolveTimeWindow({ since: '24h' })).toEqual({
startTime: '2026-07-06T12:00:00.000Z',
endTime: '2026-07-07T12:00:00.000Z',
});
});

it('rejects --until without --since', () => {
expect(() => resolveTimeWindow({ until: '1h' })).toThrow(
'--until requires --since'
);
});

it('rejects inverted windows', () => {
expect(() => resolveTimeWindow({ since: '1h', until: '2h' })).toThrow(
'--since must be earlier than --until'
);
});
});

describe('planWindowStartFromResponse', () => {
it('extracts the plan window start from page metadata', () => {
expect(
planWindowStartFromResponse({
data: [],
pageInfo: {
currentLookbackDays: 30,
maxLookbackDays: 30,
currentWindowStart: '2026-06-07T12:00:00.000Z',
maxWindowStart: '2026-06-07T12:00:00.000Z',
upgradeAvailable: false,
},
})
).toBe('2026-06-07T12:00:00.000Z');
});

it('returns undefined without page metadata', () => {
expect(planWindowStartFromResponse({ data: [] })).toBeUndefined();
expect(planWindowStartFromResponse(null)).toBeUndefined();
});
});
69 changes: 69 additions & 0 deletions packages/cli/src/lib/inspect/time-window.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
import type { AnalyticsPageInfo } from './pagination.js';

const DURATION_UNIT_MS: Record<string, number> = {
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
};

/**
* Parse a `--since`/`--until` value: either a relative duration (`30m`,
* `12h`, `7d`, `2w`, interpreted as "that long ago") or an absolute
* timestamp accepted by `Date`.
*/
export function parseTimeInput(value: string, flagName: string): Date {
const duration = /^(\d+)(m|h|d|w)$/.exec(value.trim());
if (duration) {
const [, amount, unit] = duration;
return new Date(Date.now() - Number(amount) * DURATION_UNIT_MS[unit]);
}
const absolute = new Date(value);
if (!Number.isNaN(absolute.getTime())) {
return absolute;
}
throw new Error(
`Invalid --${flagName} value "${value}". Use a relative duration (30m, 12h, 7d, 2w) or a timestamp (2026-07-01T00:00:00Z).`
);
}

/**
* Resolve `--since`/`--until` flags into an explicit listing window for the
* analytics runs API, or undefined when neither flag was given (the backend
* then applies its default window). Windows older than the plan's
* observability lookback are rejected by the backend with
* `observability-upgrade-required`.
*/
export function resolveTimeWindow(opts: {
since?: string;
until?: string;
}): { startTime: string; endTime: string } | undefined {
if (!opts.since && !opts.until) return undefined;
if (!opts.since) {
throw new Error('--until requires --since.');
}
const start = parseTimeInput(opts.since, 'since');
const end = opts.until
? parseTimeInput(opts.until, 'until')
: new Date(Date.now());
if (start.getTime() >= end.getTime()) {
throw new Error('--since must be earlier than --until.');
}
return { startTime: start.toISOString(), endTime: end.toISOString() };
}

/**
* Extract the plan observability-window start from a paginated analytics
* response, for widening a defaulted (recent-window) listing to the whole
* window the plan allows. Returns undefined when the response carries no
* page metadata (e.g. non-Vercel backends).
*/
export function planWindowStartFromResponse(
result: unknown
): string | undefined {
if (typeof result !== 'object' || result === null) return undefined;
const pageInfo = (result as { pageInfo?: AnalyticsPageInfo }).pageInfo;
if (!pageInfo?.currentWindowStart) return undefined;
const start = new Date(pageInfo.currentWindowStart);
return Number.isNaN(start.getTime()) ? undefined : start.toISOString();
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [world][web][cli] o11y: window-aware runs listing by karthikscale3 · Pull Request #2812 · vercel/workflow · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/cli-runs-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/cli': patch
---

Add `--since`/`--until` to `workflow inspect runs`; `workflow start` and bulk `workflow cancel` name lookups now search past the backend's default 24h window.
5 changes: 5 additions & 0 deletions .changeset/web-runs-infinite-scroll.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/web': patch
---

Runs list: infinite scroll with SWR-cached pages (tab switches no longer refetch), a plan-aware time-window picker (default 24h), and status filtering without requiring a workflow filter.
6 changes: 6 additions & 0 deletions .changeset/world-analytics-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/world': patch
'@workflow/world-vercel': patch
---

Support `startTime`/`endTime` on `world.analytics.runs.list` to bound the listing window.
37 changes: 29 additions & 8 deletions packages/cli/src/commands/cancel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
} from '../lib/inspect/errors.js';
import { cliFlags } from '../lib/inspect/flags.js';
import { setupCliWorld } from '../lib/inspect/setup.js';
import { planWindowStartFromResponse } from '../lib/inspect/time-window.js';

export default class Cancel extends BaseCommand {
static description =
Expand DownExpand Up@@ -110,18 +111,38 @@ export default class Cancel extends BaseCommand {
// Fetch matching runs. Only metadata is needed to display and cancel, so
// prefer the analytics read path when the backend provides one.
const status = flags.status as WorkflowRun['status'] | undefined;
const runList = world.analytics
? await world.analytics.runs.list({
const limit = flags.limit || 50;
const analytics = world.analytics;
const fetchMatches = async () => {
if (!analytics) {
return world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
})
: await world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
pagination: { limit },
resolveData: 'none',
});
}
// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend), but bulk cancel must match
// across the plan's whole observability window — a run can sleep or
// wait on a hook for days without emitting recent events. Probe for
// the plan window bounds first, then match across them.
const probe = await analytics.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: 1 },
});
const windowStart = planWindowStartFromResponse(probe);
return analytics.runs.list({
status,
workflowName: flags.workflowName,
...(windowStart
? { startTime: windowStart, endTime: new Date().toISOString() }
: {}),
pagination: { limit },
});
};
const runList = await fetchMatches();
const runs = {
data: runList.data.map((run) => ({
runId: run.runId,
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/inspect.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,6 +129,22 @@ export default class Inspect extends BaseCommand {
helpGroup: 'Filtering',
helpLabel: '--status',
}),
since: Flags.string({
description:
'list runs active since a relative duration (30m, 12h, 7d, 2w) or timestamp; defaults to the backend window (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--since',
helpValue: 'DURATION|TIMESTAMP',
}),
until: Flags.string({
description:
'end of the --since listing window, as a relative duration or timestamp; defaults to now (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--until',
helpValue: 'DURATION|TIMESTAMP',
}),
withData: Flags.boolean({
description:
'include full input/output data in list views (deprecated for list views — use `inspect <resource> <id>` to view payloads)',
Expand DownExpand Up@@ -282,6 +298,8 @@ function toInspectOptions(flags: any): InspectCLIOptions {
limit: flags.limit,
workflowName: flags.workflowName,
status: flags.status,
since: flags.since,
until: flags.until,
withData: flags.withData,
decrypt: flags.decrypt,
backend: flags.backend,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/config/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,10 @@ export type InspectCLIOptions = {
limit?: number;
workflowName?: string;
status?: string;
/** Listing window start: relative duration (12h, 7d) or timestamp (runs only) */
since?: string;
/** Listing window end: relative duration or timestamp; requires --since */
until?: string;
withData?: boolean;
backend?: string;
disableRelativeDates?: boolean;
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/lib/inspect/output.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
isEncryptedRef,
isExpiredRef,
} from './hydration.js';
import { resolveTimeWindow } from './time-window.js';

/**
* Create an EncryptionKeyResolver from a World instance.
Expand DownExpand Up@@ -620,6 +621,17 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const resolveData = opts.withData ? 'all' : 'none';
const status = opts.status as WorkflowRun['status'] | undefined;

// Resolve --since/--until into an explicit listing window. Only the
// analytics read path supports one; the runtime storage APIs have no time
// filter. Without the flags the backend applies its default window
// (trailing 24h on the Vercel backend).
const timeWindow = resolveTimeWindow(opts);
if (timeWindow && !useAnalytics) {
logger.warn(
'--since/--until require the analytics read path and are ignored by this backend.'
);
}

// Determine which props to show based on withData flag
const props = opts.withData
? WORKFLOW_RUN_LISTED_PROPS
Expand All@@ -639,6 +651,7 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const runs = await world.analytics.runs.list({
workflowName: opts.workflowName,
status,
...(timeWindow ?? {}),
pagination,
});
return {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/inspect/run.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { start } from '@workflow/core/runtime';
import { healthCheck } from '@workflow/core/runtime/helpers';
import type { World } from '@workflow/world';
import { logger } from '../config/log.js';
import { planWindowStartFromResponse } from './time-window.js';

interface CLICreateOpts {
json?: boolean;
Expand DownExpand Up@@ -57,6 +58,23 @@ export const startRun = async (
resolveData: 'none',
});
run = runList.data[0];

// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend). When the name wasn't found
// there, retry across the plan's whole observability window using the
// window bounds from the response's page metadata.
if (!run && world.analytics) {
const windowStart = planWindowStartFromResponse(runList);
if (windowStart) {
const widened = await world.analytics.runs.list({
workflowName: workflowNameOrRunId,
startTime: windowStart,
endTime: new Date().toISOString(),
pagination: { sortOrder: 'desc', limit: 1 },
});
run = widened.data[0];
}
}
}

if (!run) {
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/lib/inspect/time-window.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
parseTimeInput,
planWindowStartFromResponse,
resolveTimeWindow,
} from './time-window.js';

const NOW = new Date('2026-07-07T12:00:00.000Z');

afterEach(() => {
vi.restoreAllMocks();
});

describe('parseTimeInput', () => {
it('parses relative durations as that long ago', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(parseTimeInput('30m', 'since').toISOString()).toBe(
'2026-07-07T11:30:00.000Z'
);
expect(parseTimeInput('12h', 'since').toISOString()).toBe(
'2026-07-07T00:00:00.000Z'
);
expect(parseTimeInput('7d', 'since').toISOString()).toBe(
'2026-06-30T12:00:00.000Z'
);
expect(parseTimeInput('2w', 'since').toISOString()).toBe(
'2026-06-23T12:00:00.000Z'
);
});

it('parses absolute timestamps', () => {
expect(parseTimeInput('2026-07-01T00:00:00Z', 'since').toISOString()).toBe(
'2026-07-01T00:00:00.000Z'
);
});

it('rejects invalid values with the flag name in the error', () => {
expect(() => parseTimeInput('yesterday-ish', 'since')).toThrow('--since');
});
});

describe('resolveTimeWindow', () => {
it('returns undefined when neither flag is given', () => {
expect(resolveTimeWindow({})).toBeUndefined();
});

it('defaults the end to now when only --since is given', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(resolveTimeWindow({ since: '24h' })).toEqual({
startTime: '2026-07-06T12:00:00.000Z',
endTime: '2026-07-07T12:00:00.000Z',
});
});

it('rejects --until without --since', () => {
expect(() => resolveTimeWindow({ until: '1h' })).toThrow(
'--until requires --since'
);
});

it('rejects inverted windows', () => {
expect(() => resolveTimeWindow({ since: '1h', until: '2h' })).toThrow(
'--since must be earlier than --until'
);
});
});

describe('planWindowStartFromResponse', () => {
it('extracts the plan window start from page metadata', () => {
expect(
planWindowStartFromResponse({
data: [],
pageInfo: {
currentLookbackDays: 30,
maxLookbackDays: 30,
currentWindowStart: '2026-06-07T12:00:00.000Z',
maxWindowStart: '2026-06-07T12:00:00.000Z',
upgradeAvailable: false,
},
})
).toBe('2026-06-07T12:00:00.000Z');
});

it('returns undefined without page metadata', () => {
expect(planWindowStartFromResponse({ data: [] })).toBeUndefined();
expect(planWindowStartFromResponse(null)).toBeUndefined();
});
});
69 changes: 69 additions & 0 deletions packages/cli/src/lib/inspect/time-window.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
import type { AnalyticsPageInfo } from './pagination.js';

const DURATION_UNIT_MS: Record<string, number> = {
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
};

/**
* Parse a `--since`/`--until` value: either a relative duration (`30m`,
* `12h`, `7d`, `2w`, interpreted as "that long ago") or an absolute
* timestamp accepted by `Date`.
*/
export function parseTimeInput(value: string, flagName: string): Date {
const duration = /^(\d+)(m|h|d|w)$/.exec(value.trim());
if (duration) {
const [, amount, unit] = duration;
return new Date(Date.now() - Number(amount) * DURATION_UNIT_MS[unit]);
}
const absolute = new Date(value);
if (!Number.isNaN(absolute.getTime())) {
return absolute;
}
throw new Error(
`Invalid --${flagName} value "${value}". Use a relative duration (30m, 12h, 7d, 2w) or a timestamp (2026-07-01T00:00:00Z).`
);
}

/**
* Resolve `--since`/`--until` flags into an explicit listing window for the
* analytics runs API, or undefined when neither flag was given (the backend
* then applies its default window). Windows older than the plan's
* observability lookback are rejected by the backend with
* `observability-upgrade-required`.
*/
export function resolveTimeWindow(opts: {
since?: string;
until?: string;
}): { startTime: string; endTime: string } | undefined {
if (!opts.since && !opts.until) return undefined;
if (!opts.since) {
throw new Error('--until requires --since.');
}
const start = parseTimeInput(opts.since, 'since');
const end = opts.until
? parseTimeInput(opts.until, 'until')
: new Date(Date.now());
if (start.getTime() >= end.getTime()) {
throw new Error('--since must be earlier than --until.');
}
return { startTime: start.toISOString(), endTime: end.toISOString() };
}

/**
* Extract the plan observability-window start from a paginated analytics
* response, for widening a defaulted (recent-window) listing to the whole
* window the plan allows. Returns undefined when the response carries no
* page metadata (e.g. non-Vercel backends).
*/
export function planWindowStartFromResponse(
result: unknown
): string | undefined {
if (typeof result !== 'object' || result === null) return undefined;
const pageInfo = (result as { pageInfo?: AnalyticsPageInfo }).pageInfo;
if (!pageInfo?.currentWindowStart) return undefined;
const start = new Date(pageInfo.currentWindowStart);
return Number.isNaN(start.getTime()) ? undefined : start.toISOString();
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [world][web][cli] o11y: window-aware runs listing by karthikscale3 · Pull Request #2812 · vercel/workflow · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/cli-runs-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/cli': patch
---

Add `--since`/`--until` to `workflow inspect runs`; `workflow start` and bulk `workflow cancel` name lookups now search past the backend's default 24h window.
5 changes: 5 additions & 0 deletions .changeset/web-runs-infinite-scroll.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/web': patch
---

Runs list: infinite scroll with SWR-cached pages (tab switches no longer refetch), a plan-aware time-window picker (default 24h), and status filtering without requiring a workflow filter.
6 changes: 6 additions & 0 deletions .changeset/world-analytics-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/world': patch
'@workflow/world-vercel': patch
---

Support `startTime`/`endTime` on `world.analytics.runs.list` to bound the listing window.
37 changes: 29 additions & 8 deletions packages/cli/src/commands/cancel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
} from '../lib/inspect/errors.js';
import { cliFlags } from '../lib/inspect/flags.js';
import { setupCliWorld } from '../lib/inspect/setup.js';
import { planWindowStartFromResponse } from '../lib/inspect/time-window.js';

export default class Cancel extends BaseCommand {
static description =
Expand DownExpand Up@@ -110,18 +111,38 @@ export default class Cancel extends BaseCommand {
// Fetch matching runs. Only metadata is needed to display and cancel, so
// prefer the analytics read path when the backend provides one.
const status = flags.status as WorkflowRun['status'] | undefined;
const runList = world.analytics
? await world.analytics.runs.list({
const limit = flags.limit || 50;
const analytics = world.analytics;
const fetchMatches = async () => {
if (!analytics) {
return world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
})
: await world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
pagination: { limit },
resolveData: 'none',
});
}
// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend), but bulk cancel must match
// across the plan's whole observability window — a run can sleep or
// wait on a hook for days without emitting recent events. Probe for
// the plan window bounds first, then match across them.
const probe = await analytics.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: 1 },
});
const windowStart = planWindowStartFromResponse(probe);
return analytics.runs.list({
status,
workflowName: flags.workflowName,
...(windowStart
? { startTime: windowStart, endTime: new Date().toISOString() }
: {}),
pagination: { limit },
});
};
const runList = await fetchMatches();
const runs = {
data: runList.data.map((run) => ({
runId: run.runId,
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/inspect.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,6 +129,22 @@ export default class Inspect extends BaseCommand {
helpGroup: 'Filtering',
helpLabel: '--status',
}),
since: Flags.string({
description:
'list runs active since a relative duration (30m, 12h, 7d, 2w) or timestamp; defaults to the backend window (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--since',
helpValue: 'DURATION|TIMESTAMP',
}),
until: Flags.string({
description:
'end of the --since listing window, as a relative duration or timestamp; defaults to now (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--until',
helpValue: 'DURATION|TIMESTAMP',
}),
withData: Flags.boolean({
description:
'include full input/output data in list views (deprecated for list views — use `inspect <resource> <id>` to view payloads)',
Expand DownExpand Up@@ -282,6 +298,8 @@ function toInspectOptions(flags: any): InspectCLIOptions {
limit: flags.limit,
workflowName: flags.workflowName,
status: flags.status,
since: flags.since,
until: flags.until,
withData: flags.withData,
decrypt: flags.decrypt,
backend: flags.backend,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/config/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,10 @@ export type InspectCLIOptions = {
limit?: number;
workflowName?: string;
status?: string;
/** Listing window start: relative duration (12h, 7d) or timestamp (runs only) */
since?: string;
/** Listing window end: relative duration or timestamp; requires --since */
until?: string;
withData?: boolean;
backend?: string;
disableRelativeDates?: boolean;
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/lib/inspect/output.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
isEncryptedRef,
isExpiredRef,
} from './hydration.js';
import { resolveTimeWindow } from './time-window.js';

/**
* Create an EncryptionKeyResolver from a World instance.
Expand DownExpand Up@@ -620,6 +621,17 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const resolveData = opts.withData ? 'all' : 'none';
const status = opts.status as WorkflowRun['status'] | undefined;

// Resolve --since/--until into an explicit listing window. Only the
// analytics read path supports one; the runtime storage APIs have no time
// filter. Without the flags the backend applies its default window
// (trailing 24h on the Vercel backend).
const timeWindow = resolveTimeWindow(opts);
if (timeWindow && !useAnalytics) {
logger.warn(
'--since/--until require the analytics read path and are ignored by this backend.'
);
}

// Determine which props to show based on withData flag
const props = opts.withData
? WORKFLOW_RUN_LISTED_PROPS
Expand All@@ -639,6 +651,7 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const runs = await world.analytics.runs.list({
workflowName: opts.workflowName,
status,
...(timeWindow ?? {}),
pagination,
});
return {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/inspect/run.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { start } from '@workflow/core/runtime';
import { healthCheck } from '@workflow/core/runtime/helpers';
import type { World } from '@workflow/world';
import { logger } from '../config/log.js';
import { planWindowStartFromResponse } from './time-window.js';

interface CLICreateOpts {
json?: boolean;
Expand DownExpand Up@@ -57,6 +58,23 @@ export const startRun = async (
resolveData: 'none',
});
run = runList.data[0];

// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend). When the name wasn't found
// there, retry across the plan's whole observability window using the
// window bounds from the response's page metadata.
if (!run && world.analytics) {
const windowStart = planWindowStartFromResponse(runList);
if (windowStart) {
const widened = await world.analytics.runs.list({
workflowName: workflowNameOrRunId,
startTime: windowStart,
endTime: new Date().toISOString(),
pagination: { sortOrder: 'desc', limit: 1 },
});
run = widened.data[0];
}
}
}

if (!run) {
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/lib/inspect/time-window.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
parseTimeInput,
planWindowStartFromResponse,
resolveTimeWindow,
} from './time-window.js';

const NOW = new Date('2026-07-07T12:00:00.000Z');

afterEach(() => {
vi.restoreAllMocks();
});

describe('parseTimeInput', () => {
it('parses relative durations as that long ago', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(parseTimeInput('30m', 'since').toISOString()).toBe(
'2026-07-07T11:30:00.000Z'
);
expect(parseTimeInput('12h', 'since').toISOString()).toBe(
'2026-07-07T00:00:00.000Z'
);
expect(parseTimeInput('7d', 'since').toISOString()).toBe(
'2026-06-30T12:00:00.000Z'
);
expect(parseTimeInput('2w', 'since').toISOString()).toBe(
'2026-06-23T12:00:00.000Z'
);
});

it('parses absolute timestamps', () => {
expect(parseTimeInput('2026-07-01T00:00:00Z', 'since').toISOString()).toBe(
'2026-07-01T00:00:00.000Z'
);
});

it('rejects invalid values with the flag name in the error', () => {
expect(() => parseTimeInput('yesterday-ish', 'since')).toThrow('--since');
});
});

describe('resolveTimeWindow', () => {
it('returns undefined when neither flag is given', () => {
expect(resolveTimeWindow({})).toBeUndefined();
});

it('defaults the end to now when only --since is given', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(resolveTimeWindow({ since: '24h' })).toEqual({
startTime: '2026-07-06T12:00:00.000Z',
endTime: '2026-07-07T12:00:00.000Z',
});
});

it('rejects --until without --since', () => {
expect(() => resolveTimeWindow({ until: '1h' })).toThrow(
'--until requires --since'
);
});

it('rejects inverted windows', () => {
expect(() => resolveTimeWindow({ since: '1h', until: '2h' })).toThrow(
'--since must be earlier than --until'
);
});
});

describe('planWindowStartFromResponse', () => {
it('extracts the plan window start from page metadata', () => {
expect(
planWindowStartFromResponse({
data: [],
pageInfo: {
currentLookbackDays: 30,
maxLookbackDays: 30,
currentWindowStart: '2026-06-07T12:00:00.000Z',
maxWindowStart: '2026-06-07T12:00:00.000Z',
upgradeAvailable: false,
},
})
).toBe('2026-06-07T12:00:00.000Z');
});

it('returns undefined without page metadata', () => {
expect(planWindowStartFromResponse({ data: [] })).toBeUndefined();
expect(planWindowStartFromResponse(null)).toBeUndefined();
});
});
69 changes: 69 additions & 0 deletions packages/cli/src/lib/inspect/time-window.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
import type { AnalyticsPageInfo } from './pagination.js';

const DURATION_UNIT_MS: Record<string, number> = {
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
};

/**
* Parse a `--since`/`--until` value: either a relative duration (`30m`,
* `12h`, `7d`, `2w`, interpreted as "that long ago") or an absolute
* timestamp accepted by `Date`.
*/
export function parseTimeInput(value: string, flagName: string): Date {
const duration = /^(\d+)(m|h|d|w)$/.exec(value.trim());
if (duration) {
const [, amount, unit] = duration;
return new Date(Date.now() - Number(amount) * DURATION_UNIT_MS[unit]);
}
const absolute = new Date(value);
if (!Number.isNaN(absolute.getTime())) {
return absolute;
}
throw new Error(
`Invalid --${flagName} value "${value}". Use a relative duration (30m, 12h, 7d, 2w) or a timestamp (2026-07-01T00:00:00Z).`
);
}

/**
* Resolve `--since`/`--until` flags into an explicit listing window for the
* analytics runs API, or undefined when neither flag was given (the backend
* then applies its default window). Windows older than the plan's
* observability lookback are rejected by the backend with
* `observability-upgrade-required`.
*/
export function resolveTimeWindow(opts: {
since?: string;
until?: string;
}): { startTime: string; endTime: string } | undefined {
if (!opts.since && !opts.until) return undefined;
if (!opts.since) {
throw new Error('--until requires --since.');
}
const start = parseTimeInput(opts.since, 'since');
const end = opts.until
? parseTimeInput(opts.until, 'until')
: new Date(Date.now());
if (start.getTime() >= end.getTime()) {
throw new Error('--since must be earlier than --until.');
}
return { startTime: start.toISOString(), endTime: end.toISOString() };
}

/**
* Extract the plan observability-window start from a paginated analytics
* response, for widening a defaulted (recent-window) listing to the whole
* window the plan allows. Returns undefined when the response carries no
* page metadata (e.g. non-Vercel backends).
*/
export function planWindowStartFromResponse(
result: unknown
): string | undefined {
if (typeof result !== 'object' || result === null) return undefined;
const pageInfo = (result as { pageInfo?: AnalyticsPageInfo }).pageInfo;
if (!pageInfo?.currentWindowStart) return undefined;
const start = new Date(pageInfo.currentWindowStart);
return Number.isNaN(start.getTime()) ? undefined : start.toISOString();
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [world][web][cli] o11y: window-aware runs listing by karthikscale3 · Pull Request #2812 · vercel/workflow · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/cli-runs-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/cli': patch
---

Add `--since`/`--until` to `workflow inspect runs`; `workflow start` and bulk `workflow cancel` name lookups now search past the backend's default 24h window.
5 changes: 5 additions & 0 deletions .changeset/web-runs-infinite-scroll.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/web': patch
---

Runs list: infinite scroll with SWR-cached pages (tab switches no longer refetch), a plan-aware time-window picker (default 24h), and status filtering without requiring a workflow filter.
6 changes: 6 additions & 0 deletions .changeset/world-analytics-time-window.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/world': patch
'@workflow/world-vercel': patch
---

Support `startTime`/`endTime` on `world.analytics.runs.list` to bound the listing window.
37 changes: 29 additions & 8 deletions packages/cli/src/commands/cancel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
} from '../lib/inspect/errors.js';
import { cliFlags } from '../lib/inspect/flags.js';
import { setupCliWorld } from '../lib/inspect/setup.js';
import { planWindowStartFromResponse } from '../lib/inspect/time-window.js';

export default class Cancel extends BaseCommand {
static description =
Expand DownExpand Up@@ -110,18 +111,38 @@ export default class Cancel extends BaseCommand {
// Fetch matching runs. Only metadata is needed to display and cancel, so
// prefer the analytics read path when the backend provides one.
const status = flags.status as WorkflowRun['status'] | undefined;
const runList = world.analytics
? await world.analytics.runs.list({
const limit = flags.limit || 50;
const analytics = world.analytics;
const fetchMatches = async () => {
if (!analytics) {
return world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
})
: await world.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: flags.limit || 50 },
pagination: { limit },
resolveData: 'none',
});
}
// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend), but bulk cancel must match
// across the plan's whole observability window — a run can sleep or
// wait on a hook for days without emitting recent events. Probe for
// the plan window bounds first, then match across them.
const probe = await analytics.runs.list({
status,
workflowName: flags.workflowName,
pagination: { limit: 1 },
});
const windowStart = planWindowStartFromResponse(probe);
return analytics.runs.list({
status,
workflowName: flags.workflowName,
...(windowStart
? { startTime: windowStart, endTime: new Date().toISOString() }
: {}),
pagination: { limit },
});
};
const runList = await fetchMatches();
const runs = {
data: runList.data.map((run) => ({
runId: run.runId,
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/inspect.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,6 +129,22 @@ export default class Inspect extends BaseCommand {
helpGroup: 'Filtering',
helpLabel: '--status',
}),
since: Flags.string({
description:
'list runs active since a relative duration (30m, 12h, 7d, 2w) or timestamp; defaults to the backend window (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--since',
helpValue: 'DURATION|TIMESTAMP',
}),
until: Flags.string({
description:
'end of the --since listing window, as a relative duration or timestamp; defaults to now (only for runs)',
required: false,
helpGroup: 'Filtering',
helpLabel: '--until',
helpValue: 'DURATION|TIMESTAMP',
}),
withData: Flags.boolean({
description:
'include full input/output data in list views (deprecated for list views — use `inspect <resource> <id>` to view payloads)',
Expand DownExpand Up@@ -282,6 +298,8 @@ function toInspectOptions(flags: any): InspectCLIOptions {
limit: flags.limit,
workflowName: flags.workflowName,
status: flags.status,
since: flags.since,
until: flags.until,
withData: flags.withData,
decrypt: flags.decrypt,
backend: flags.backend,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/config/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,10 @@ export type InspectCLIOptions = {
limit?: number;
workflowName?: string;
status?: string;
/** Listing window start: relative duration (12h, 7d) or timestamp (runs only) */
since?: string;
/** Listing window end: relative duration or timestamp; requires --since */
until?: string;
withData?: boolean;
backend?: string;
disableRelativeDates?: boolean;
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/lib/inspect/output.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import {
isEncryptedRef,
isExpiredRef,
} from './hydration.js';
import { resolveTimeWindow } from './time-window.js';

/**
* Create an EncryptionKeyResolver from a World instance.
Expand DownExpand Up@@ -620,6 +621,17 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const resolveData = opts.withData ? 'all' : 'none';
const status = opts.status as WorkflowRun['status'] | undefined;

// Resolve --since/--until into an explicit listing window. Only the
// analytics read path supports one; the runtime storage APIs have no time
// filter. Without the flags the backend applies its default window
// (trailing 24h on the Vercel backend).
const timeWindow = resolveTimeWindow(opts);
if (timeWindow && !useAnalytics) {
logger.warn(
'--since/--until require the analytics read path and are ignored by this backend.'
);
}

// Determine which props to show based on withData flag
const props = opts.withData
? WORKFLOW_RUN_LISTED_PROPS
Expand All@@ -639,6 +651,7 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => {
const runs = await world.analytics.runs.list({
workflowName: opts.workflowName,
status,
...(timeWindow ?? {}),
pagination,
});
return {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/inspect/run.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { start } from '@workflow/core/runtime';
import { healthCheck } from '@workflow/core/runtime/helpers';
import type { World } from '@workflow/world';
import { logger } from '../config/log.js';
import { planWindowStartFromResponse } from './time-window.js';

interface CLICreateOpts {
json?: boolean;
Expand DownExpand Up@@ -57,6 +58,23 @@ export const startRun = async (
resolveData: 'none',
});
run = runList.data[0];

// The analytics backend defaults its listing to a recent window
// (trailing 24h on the Vercel backend). When the name wasn't found
// there, retry across the plan's whole observability window using the
// window bounds from the response's page metadata.
if (!run && world.analytics) {
const windowStart = planWindowStartFromResponse(runList);
if (windowStart) {
const widened = await world.analytics.runs.list({
workflowName: workflowNameOrRunId,
startTime: windowStart,
endTime: new Date().toISOString(),
pagination: { sortOrder: 'desc', limit: 1 },
});
run = widened.data[0];
}
}
}

if (!run) {
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/lib/inspect/time-window.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
parseTimeInput,
planWindowStartFromResponse,
resolveTimeWindow,
} from './time-window.js';

const NOW = new Date('2026-07-07T12:00:00.000Z');

afterEach(() => {
vi.restoreAllMocks();
});

describe('parseTimeInput', () => {
it('parses relative durations as that long ago', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(parseTimeInput('30m', 'since').toISOString()).toBe(
'2026-07-07T11:30:00.000Z'
);
expect(parseTimeInput('12h', 'since').toISOString()).toBe(
'2026-07-07T00:00:00.000Z'
);
expect(parseTimeInput('7d', 'since').toISOString()).toBe(
'2026-06-30T12:00:00.000Z'
);
expect(parseTimeInput('2w', 'since').toISOString()).toBe(
'2026-06-23T12:00:00.000Z'
);
});

it('parses absolute timestamps', () => {
expect(parseTimeInput('2026-07-01T00:00:00Z', 'since').toISOString()).toBe(
'2026-07-01T00:00:00.000Z'
);
});

it('rejects invalid values with the flag name in the error', () => {
expect(() => parseTimeInput('yesterday-ish', 'since')).toThrow('--since');
});
});

describe('resolveTimeWindow', () => {
it('returns undefined when neither flag is given', () => {
expect(resolveTimeWindow({})).toBeUndefined();
});

it('defaults the end to now when only --since is given', () => {
vi.spyOn(Date, 'now').mockReturnValue(NOW.getTime());
expect(resolveTimeWindow({ since: '24h' })).toEqual({
startTime: '2026-07-06T12:00:00.000Z',
endTime: '2026-07-07T12:00:00.000Z',
});
});

it('rejects --until without --since', () => {
expect(() => resolveTimeWindow({ until: '1h' })).toThrow(
'--until requires --since'
);
});

it('rejects inverted windows', () => {
expect(() => resolveTimeWindow({ since: '1h', until: '2h' })).toThrow(
'--since must be earlier than --until'
);
});
});

describe('planWindowStartFromResponse', () => {
it('extracts the plan window start from page metadata', () => {
expect(
planWindowStartFromResponse({
data: [],
pageInfo: {
currentLookbackDays: 30,
maxLookbackDays: 30,
currentWindowStart: '2026-06-07T12:00:00.000Z',
maxWindowStart: '2026-06-07T12:00:00.000Z',
upgradeAvailable: false,
},
})
).toBe('2026-06-07T12:00:00.000Z');
});

it('returns undefined without page metadata', () => {
expect(planWindowStartFromResponse({ data: [] })).toBeUndefined();
expect(planWindowStartFromResponse(null)).toBeUndefined();
});
});
69 changes: 69 additions & 0 deletions packages/cli/src/lib/inspect/time-window.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
import type { AnalyticsPageInfo } from './pagination.js';

const DURATION_UNIT_MS: Record<string, number> = {
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
};

/**
* Parse a `--since`/`--until` value: either a relative duration (`30m`,
* `12h`, `7d`, `2w`, interpreted as "that long ago") or an absolute
* timestamp accepted by `Date`.
*/
export function parseTimeInput(value: string, flagName: string): Date {
const duration = /^(\d+)(m|h|d|w)$/.exec(value.trim());
if (duration) {
const [, amount, unit] = duration;
return new Date(Date.now() - Number(amount) * DURATION_UNIT_MS[unit]);
}
const absolute = new Date(value);
if (!Number.isNaN(absolute.getTime())) {
return absolute;
}
throw new Error(
`Invalid --${flagName} value "${value}". Use a relative duration (30m, 12h, 7d, 2w) or a timestamp (2026-07-01T00:00:00Z).`
);
}

/**
* Resolve `--since`/`--until` flags into an explicit listing window for the
* analytics runs API, or undefined when neither flag was given (the backend
* then applies its default window). Windows older than the plan's
* observability lookback are rejected by the backend with
* `observability-upgrade-required`.
*/
export function resolveTimeWindow(opts: {
since?: string;
until?: string;
}): { startTime: string; endTime: string } | undefined {
if (!opts.since && !opts.until) return undefined;
if (!opts.since) {
throw new Error('--until requires --since.');
}
const start = parseTimeInput(opts.since, 'since');
const end = opts.until
? parseTimeInput(opts.until, 'until')
: new Date(Date.now());
if (start.getTime() >= end.getTime()) {
throw new Error('--since must be earlier than --until.');
}
return { startTime: start.toISOString(), endTime: end.toISOString() };
}

/**
* Extract the plan observability-window start from a paginated analytics
* response, for widening a defaulted (recent-window) listing to the whole
* window the plan allows. Returns undefined when the response carries no
* page metadata (e.g. non-Vercel backends).
*/
export function planWindowStartFromResponse(
result: unknown
): string | undefined {
if (typeof result !== 'object' || result === null) return undefined;
const pageInfo = (result as { pageInfo?: AnalyticsPageInfo }).pageInfo;
if (!pageInfo?.currentWindowStart) return undefined;
const start = new Date(pageInfo.currentWindowStart);
return Number.isNaN(start.getTime()) ? undefined : start.toISOString();
}
Loading
Loading