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
6 changes: 6 additions & 0 deletions .changeset/start-cross-run-lineage.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"@workflow/world": patch
---

Record cross-run lineage when `start()` is called from inside a workflow or step: the new run is tagged with `$parentRunId` (its direct parent) and inherits the parent's `$rootRunId`, so a daisy chain or fan-out of any depth groups under one root id.
9 changes: 9 additions & 0 deletions docs/content/docs/v5/observability/attributes.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,15 @@ export async function cleanupAttributes() {

Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.

## Reserved keys

When `start()` is called from inside a running workflow or step, the new run is automatically tagged with two reserved attributes:

- `$parentRunId`: the run that started it.
- `$rootRunId`: the root of the chain. It is inherited, so every run in a daisy chain or fan-out shares one root id.

Top-level runs (started outside any workflow or step) are not tagged.

## Viewing attributes

The run details panel in the observability UI shows the run's current attributes as key-value rows. Reserved `$`-prefixed keys are marked with a badge and sorted after user keys:
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ import {
import {
type Event,
getQueueTopicPrefix,
ROOT_RUN_ID_ATTRIBUTE,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand DownExpand Up@@ -242,6 +243,17 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
return true;
}

/**
* The lineage root of a loaded run: its `$rootRunId` attribute, or its own id
* when it is itself a root.
*/
function rootRunIdFrom(
attributes: Record<string, string> | undefined,
runId: string
): string {
return attributes?.[ROOT_RUN_ID_ATTRIBUTE] ?? runId;
}

/**
* Whether the run has any hook or wait that an out-of-band writer could
* append an event for between an inline step's `step_completed` write and
Expand DownExpand Up@@ -728,6 +740,7 @@ export function workflowEntrypoint(
workflowDeploymentId: bgRun.deploymentId,
workflowName,
workflowStartedAt: bgStartedAt,
rootRunId: rootRunIdFrom(bgRun.attributes, runId),
stepId: incomingStepId,
stepName: incomingStepName,
runSpecVersion: bgRun.specVersion,
Expand DownExpand Up@@ -1984,6 +1997,10 @@ export function workflowEntrypoint(
workflowRun.deploymentId,
workflowName,
workflowStartedAt,
rootRunId: rootRunIdFrom(
workflowRun.attributes,
runId
),
stepId: s.correlationId,
stepName: s.stepName,
runSpecVersion: workflowRun.specVersion,
Expand Down
156 changes: 156 additions & 0 deletions packages/core/src/runtime/start-lineage.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import {
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
} from '@workflow/world';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { contextStorage } from '../step/context-storage.js';
import { start } from './start.js';
import { setWorld } from './world.js';

vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() }));
vi.mock('../telemetry.js', () => ({
serializeTraceCarrier: vi.fn().mockResolvedValue({}),
trace: vi.fn((_name, fn) => fn(undefined)),
}));

/**
* Cross-run lineage: start() records reserved `$rootRunId` / `$parentRunId`
* attributes when it runs inside another run, so a daisy chain or fan-out
* groups under one root id. The lineage is a pure read of the ambient step
* context: the runtime fills both the parent run id and its root from the run
* it already has loaded, so start() never reads back the parent (`runs.get` is
* never called). A top-level start() records nothing.
*/
describe('start() cross-run lineage', () => {
let eventsCreate: ReturnType<typeof vi.fn>;
let runsGet: ReturnType<typeof vi.fn>;
let queue: ReturnType<typeof vi.fn>;

// The runtime requires the world to declare the current spec version; the
// per-run spec is driven separately via `opts.specVersion` where needed.
function useWorld() {
setWorld({
specVersion: SPEC_VERSION_CURRENT,
getDeploymentId: vi.fn().mockResolvedValue('deploy_123'),
events: { create: eventsCreate },
runs: { get: runsGet },
queue,
} as any);
}

beforeEach(() => {
eventsCreate = vi.fn().mockImplementation((runId) =>
Promise.resolve({
run: { runId: runId ?? 'wrun_x', status: 'pending' },
})
);
runsGet = vi.fn();
queue = vi.fn().mockResolvedValue(undefined);
useWorld();
});

afterEach(() => {
setWorld(undefined);
vi.clearAllMocks();
});

const wf = (id: string) =>
Object.assign(() => Promise.resolve('ok'), { workflowId: id });

/** Attributes seeded onto the run_created event for the first start() call. */
function seededAttributes(): Record<string, string> | undefined {
return eventsCreate.mock.calls[0]?.[1]?.eventData?.attributes;
}

/**
* Run `fn` as if executing inside a parent run's step context. Pass
* `rootRunId` to model the runtime having put the parent's root on the
* context (the wired path); omit it to exercise the anchor-to-parent default.
*/
function insideRun<T>(
parentRunId: string,
fn: () => Promise<T>,
rootRunId?: string
): Promise<T> {
return contextStorage.run(
{
stepMetadata: {
stepName: 'start',
stepId: 'step_1',
stepStartedAt: new Date(),
attempt: 1,
},
workflowMetadata: {
workflowName: 'parent',
workflowRunId: parentRunId,
workflowStartedAt: new Date(),
url: 'http://localhost:3000',
features: { encryption: false },
},
rootRunId,
ops: [],
} as any,
fn
);
}

it('records no lineage for a top-level start()', async () => {
await start(wf('test-workflow'), []);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});

it('inherits the root from the context with no read-back', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), []),
'wrun_root'
);

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
});
});

it('anchors the root to the parent when the context carries none', async () => {
await insideRun('wrun_parent', () => start(wf('child-workflow'), []));

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_parent',
$parentRunId: 'wrun_parent',
});
});

it('merges caller-provided attributes over the inferred lineage', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), [], { attributes: { tenant: 't1' } }),
'wrun_root'
);

const expected = {
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
tenant: 't1',
};
expect(seededAttributes()).toEqual(expected);
// Lineage must also ride the resilient-start queue input, not just the
// run_created event, so both creation paths carry it.
expect(queue.mock.calls[0]?.[1]?.runInput?.attributes).toEqual(expected);
});

it('records no lineage when the run predates attribute support', async () => {
await insideRun('wrun_parent', () =>
start(wf('child-workflow'), [], {
specVersion: SPEC_VERSION_SUPPORTS_ATTRIBUTES - 1,
})
);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});
});
48 changes: 42 additions & 6 deletions packages/core/src/runtime/start.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import { workflowDisplayName } from '@workflow/utils/parse-name';
import type { WorkflowInvokePayload, World } from '@workflow/world';
import {
isLegacySpecVersion,
PARENT_RUN_ID_ATTRIBUTE,
ROOT_RUN_ID_ATTRIBUTE,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand All@@ -19,6 +21,7 @@ import {
dehydrateWorkflowArguments,
SerializationFormat,
} from '../serialization.js';
import { contextStorage } from '../step/context-storage.js';
import * as Attribute from '../telemetry/semantic-conventions.js';
import { serializeTraceCarrier, trace } from '../telemetry.js';
import { version as workflowCoreVersion } from '../version.js';
Expand All@@ -42,6 +45,28 @@ const CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS = 2_000;
/** ULID generator for client-side runId generation */
const ulid = monotonicFactory();

/**
* Cross-run lineage for a run being started from inside another run.
*
* The ambient step context carries the parent run id and the root of its
* lineage; the runtime fills both from the run it already has loaded, so this
* is a pure context read with no I/O. The new run records `$parentRunId` (the
* edge) and inherits the parent's `$rootRunId` (the parent itself when it is a
* root), so a daisy chain or fan-out of any depth groups under one root id.
* Returns `undefined` for a top-level `start()`, which has no context, so
* standalone runs carry no lineage.
*/
function resolveLineageAttributes(): Record<string, string> | undefined {
const store = contextStorage.getStore();
const parentRunId = store?.workflowMetadata?.workflowRunId;
if (!parentRunId) return undefined;

return {
[ROOT_RUN_ID_ATTRIBUTE]: store.rootRunId ?? parentRunId,
[PARENT_RUN_ID_ATTRIBUTE]: parentRunId,
};
}

// `deploymentId: 'latest'` is a no-op in Worlds without atomic deployments.
// The warning that explains this only needs to fire once per process: a
// workflow that hardcodes 'latest' for its Vercel deployment would otherwise
Expand DownExpand Up@@ -360,13 +385,24 @@ export async function start<TArgs extends unknown[], TResult>(
changes.map(({ key, value }) => [key, value as string])
);
}
// Seed payload shared by run_created and the resilient-start queue
// input. The flag rides along so server-side validation matches the
// client-side check above on both paths.
const attributeSeed = attributes

// Cross-run lineage: the reserved keys ride on the run's existing
// attributes, so they add no extra write. Caller attributes are spread
// last, so a caller with allowReservedAttributes can deliberately
// re-parent.
const lineage =
specVersion >= SPEC_VERSION_SUPPORTS_ATTRIBUTES
? resolveLineageAttributes()
: undefined;
const runAttributes = lineage
? { ...lineage, ...attributes }
: attributes;

// Shared by the run_created event and the resilient-start queue input.
const attributeSeed = runAttributes
? {
attributes,
...(allowReservedAttributes
attributes: runAttributes,
...(allowReservedAttributes || lineage != null
? { allowReservedAttributes: true as const }
: {}),
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runtime/step-executor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,8 @@ export interface StepExecutorParams {
workflowDeploymentId?: string;
workflowName: string;
workflowStartedAt: number;
/** Root run id of this run's lineage, carried into the step context. */
rootRunId?: string;
stepId: string;
stepName: string;
encryptionKey?: CryptoKey;
Expand DownExpand Up@@ -690,6 +692,7 @@ export async function executeStep(
features: { encryption: !!encryptionKey },
},
workflowDeploymentId: params.workflowDeploymentId,
rootRunId: params.rootRunId,
ops,
preCompletionOps,
closureVars: hydratedInput.closureVars,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/step/context-storage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ export type StepContext = {
workflowMetadata: WorkflowMetadata;
/** Deployment that owns the current workflow run, used for forwarded streams. */
workflowDeploymentId?: string;
/**
* Root run id of the current run's lineage (`$rootRunId`), so a nested
* `start()` inherits it without reloading the parent. Set by the runtime from
* the run it already has loaded.
*/
rootRunId?: string;
ops: Promise<void>[];
/**
* Operations that MUST be durably committed before the step's
Expand Down
2 changes: 2 additions & 0 deletions packages/world-testing/src/index.mts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import { errors } from './errors.mjs';
import { hooks } from './hooks.mjs';
import { idempotency } from './idempotency.mjs';
import { inlineExecution } from './inline-execution.mjs';
import { lineage } from './lineage.mjs';
import { nullByte } from './null-byte.mjs';

export function createTestSuite(pkgName: string) {
Expand All@@ -12,4 +13,5 @@ export function createTestSuite(pkgName: string) {
nullByte(pkgName);
errors(pkgName);
inlineExecution(pkgName);
lineage(pkgName);
}
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" + '
feat: cross-run lineage via reserved run attributes by rchasman · Pull Request #2153 · 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
6 changes: 6 additions & 0 deletions .changeset/start-cross-run-lineage.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"@workflow/world": patch
---

Record cross-run lineage when `start()` is called from inside a workflow or step: the new run is tagged with `$parentRunId` (its direct parent) and inherits the parent's `$rootRunId`, so a daisy chain or fan-out of any depth groups under one root id.
9 changes: 9 additions & 0 deletions docs/content/docs/v5/observability/attributes.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,15 @@ export async function cleanupAttributes() {

Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.

## Reserved keys

When `start()` is called from inside a running workflow or step, the new run is automatically tagged with two reserved attributes:

- `$parentRunId`: the run that started it.
- `$rootRunId`: the root of the chain. It is inherited, so every run in a daisy chain or fan-out shares one root id.

Top-level runs (started outside any workflow or step) are not tagged.

## Viewing attributes

The run details panel in the observability UI shows the run's current attributes as key-value rows. Reserved `$`-prefixed keys are marked with a badge and sorted after user keys:
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ import {
import {
type Event,
getQueueTopicPrefix,
ROOT_RUN_ID_ATTRIBUTE,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand DownExpand Up@@ -242,6 +243,17 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
return true;
}

/**
* The lineage root of a loaded run: its `$rootRunId` attribute, or its own id
* when it is itself a root.
*/
function rootRunIdFrom(
attributes: Record<string, string> | undefined,
runId: string
): string {
return attributes?.[ROOT_RUN_ID_ATTRIBUTE] ?? runId;
}

/**
* Whether the run has any hook or wait that an out-of-band writer could
* append an event for between an inline step's `step_completed` write and
Expand DownExpand Up@@ -728,6 +740,7 @@ export function workflowEntrypoint(
workflowDeploymentId: bgRun.deploymentId,
workflowName,
workflowStartedAt: bgStartedAt,
rootRunId: rootRunIdFrom(bgRun.attributes, runId),
stepId: incomingStepId,
stepName: incomingStepName,
runSpecVersion: bgRun.specVersion,
Expand DownExpand Up@@ -1984,6 +1997,10 @@ export function workflowEntrypoint(
workflowRun.deploymentId,
workflowName,
workflowStartedAt,
rootRunId: rootRunIdFrom(
workflowRun.attributes,
runId
),
stepId: s.correlationId,
stepName: s.stepName,
runSpecVersion: workflowRun.specVersion,
Expand Down
156 changes: 156 additions & 0 deletions packages/core/src/runtime/start-lineage.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import {
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
} from '@workflow/world';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { contextStorage } from '../step/context-storage.js';
import { start } from './start.js';
import { setWorld } from './world.js';

vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() }));
vi.mock('../telemetry.js', () => ({
serializeTraceCarrier: vi.fn().mockResolvedValue({}),
trace: vi.fn((_name, fn) => fn(undefined)),
}));

/**
* Cross-run lineage: start() records reserved `$rootRunId` / `$parentRunId`
* attributes when it runs inside another run, so a daisy chain or fan-out
* groups under one root id. The lineage is a pure read of the ambient step
* context: the runtime fills both the parent run id and its root from the run
* it already has loaded, so start() never reads back the parent (`runs.get` is
* never called). A top-level start() records nothing.
*/
describe('start() cross-run lineage', () => {
let eventsCreate: ReturnType<typeof vi.fn>;
let runsGet: ReturnType<typeof vi.fn>;
let queue: ReturnType<typeof vi.fn>;

// The runtime requires the world to declare the current spec version; the
// per-run spec is driven separately via `opts.specVersion` where needed.
function useWorld() {
setWorld({
specVersion: SPEC_VERSION_CURRENT,
getDeploymentId: vi.fn().mockResolvedValue('deploy_123'),
events: { create: eventsCreate },
runs: { get: runsGet },
queue,
} as any);
}

beforeEach(() => {
eventsCreate = vi.fn().mockImplementation((runId) =>
Promise.resolve({
run: { runId: runId ?? 'wrun_x', status: 'pending' },
})
);
runsGet = vi.fn();
queue = vi.fn().mockResolvedValue(undefined);
useWorld();
});

afterEach(() => {
setWorld(undefined);
vi.clearAllMocks();
});

const wf = (id: string) =>
Object.assign(() => Promise.resolve('ok'), { workflowId: id });

/** Attributes seeded onto the run_created event for the first start() call. */
function seededAttributes(): Record<string, string> | undefined {
return eventsCreate.mock.calls[0]?.[1]?.eventData?.attributes;
}

/**
* Run `fn` as if executing inside a parent run's step context. Pass
* `rootRunId` to model the runtime having put the parent's root on the
* context (the wired path); omit it to exercise the anchor-to-parent default.
*/
function insideRun<T>(
parentRunId: string,
fn: () => Promise<T>,
rootRunId?: string
): Promise<T> {
return contextStorage.run(
{
stepMetadata: {
stepName: 'start',
stepId: 'step_1',
stepStartedAt: new Date(),
attempt: 1,
},
workflowMetadata: {
workflowName: 'parent',
workflowRunId: parentRunId,
workflowStartedAt: new Date(),
url: 'http://localhost:3000',
features: { encryption: false },
},
rootRunId,
ops: [],
} as any,
fn
);
}

it('records no lineage for a top-level start()', async () => {
await start(wf('test-workflow'), []);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});

it('inherits the root from the context with no read-back', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), []),
'wrun_root'
);

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
});
});

it('anchors the root to the parent when the context carries none', async () => {
await insideRun('wrun_parent', () => start(wf('child-workflow'), []));

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_parent',
$parentRunId: 'wrun_parent',
});
});

it('merges caller-provided attributes over the inferred lineage', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), [], { attributes: { tenant: 't1' } }),
'wrun_root'
);

const expected = {
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
tenant: 't1',
};
expect(seededAttributes()).toEqual(expected);
// Lineage must also ride the resilient-start queue input, not just the
// run_created event, so both creation paths carry it.
expect(queue.mock.calls[0]?.[1]?.runInput?.attributes).toEqual(expected);
});

it('records no lineage when the run predates attribute support', async () => {
await insideRun('wrun_parent', () =>
start(wf('child-workflow'), [], {
specVersion: SPEC_VERSION_SUPPORTS_ATTRIBUTES - 1,
})
);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});
});
48 changes: 42 additions & 6 deletions packages/core/src/runtime/start.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import { workflowDisplayName } from '@workflow/utils/parse-name';
import type { WorkflowInvokePayload, World } from '@workflow/world';
import {
isLegacySpecVersion,
PARENT_RUN_ID_ATTRIBUTE,
ROOT_RUN_ID_ATTRIBUTE,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand All@@ -19,6 +21,7 @@ import {
dehydrateWorkflowArguments,
SerializationFormat,
} from '../serialization.js';
import { contextStorage } from '../step/context-storage.js';
import * as Attribute from '../telemetry/semantic-conventions.js';
import { serializeTraceCarrier, trace } from '../telemetry.js';
import { version as workflowCoreVersion } from '../version.js';
Expand All@@ -42,6 +45,28 @@ const CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS = 2_000;
/** ULID generator for client-side runId generation */
const ulid = monotonicFactory();

/**
* Cross-run lineage for a run being started from inside another run.
*
* The ambient step context carries the parent run id and the root of its
* lineage; the runtime fills both from the run it already has loaded, so this
* is a pure context read with no I/O. The new run records `$parentRunId` (the
* edge) and inherits the parent's `$rootRunId` (the parent itself when it is a
* root), so a daisy chain or fan-out of any depth groups under one root id.
* Returns `undefined` for a top-level `start()`, which has no context, so
* standalone runs carry no lineage.
*/
function resolveLineageAttributes(): Record<string, string> | undefined {
const store = contextStorage.getStore();
const parentRunId = store?.workflowMetadata?.workflowRunId;
if (!parentRunId) return undefined;

return {
[ROOT_RUN_ID_ATTRIBUTE]: store.rootRunId ?? parentRunId,
[PARENT_RUN_ID_ATTRIBUTE]: parentRunId,
};
}

// `deploymentId: 'latest'` is a no-op in Worlds without atomic deployments.
// The warning that explains this only needs to fire once per process: a
// workflow that hardcodes 'latest' for its Vercel deployment would otherwise
Expand DownExpand Up@@ -360,13 +385,24 @@ export async function start<TArgs extends unknown[], TResult>(
changes.map(({ key, value }) => [key, value as string])
);
}
// Seed payload shared by run_created and the resilient-start queue
// input. The flag rides along so server-side validation matches the
// client-side check above on both paths.
const attributeSeed = attributes

// Cross-run lineage: the reserved keys ride on the run's existing
// attributes, so they add no extra write. Caller attributes are spread
// last, so a caller with allowReservedAttributes can deliberately
// re-parent.
const lineage =
specVersion >= SPEC_VERSION_SUPPORTS_ATTRIBUTES
? resolveLineageAttributes()
: undefined;
const runAttributes = lineage
? { ...lineage, ...attributes }
: attributes;

// Shared by the run_created event and the resilient-start queue input.
const attributeSeed = runAttributes
? {
attributes,
...(allowReservedAttributes
attributes: runAttributes,
...(allowReservedAttributes || lineage != null
? { allowReservedAttributes: true as const }
: {}),
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runtime/step-executor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,8 @@ export interface StepExecutorParams {
workflowDeploymentId?: string;
workflowName: string;
workflowStartedAt: number;
/** Root run id of this run's lineage, carried into the step context. */
rootRunId?: string;
stepId: string;
stepName: string;
encryptionKey?: CryptoKey;
Expand DownExpand Up@@ -690,6 +692,7 @@ export async function executeStep(
features: { encryption: !!encryptionKey },
},
workflowDeploymentId: params.workflowDeploymentId,
rootRunId: params.rootRunId,
ops,
preCompletionOps,
closureVars: hydratedInput.closureVars,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/step/context-storage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ export type StepContext = {
workflowMetadata: WorkflowMetadata;
/** Deployment that owns the current workflow run, used for forwarded streams. */
workflowDeploymentId?: string;
/**
* Root run id of the current run's lineage (`$rootRunId`), so a nested
* `start()` inherits it without reloading the parent. Set by the runtime from
* the run it already has loaded.
*/
rootRunId?: string;
ops: Promise<void>[];
/**
* Operations that MUST be durably committed before the step's
Expand Down
2 changes: 2 additions & 0 deletions packages/world-testing/src/index.mts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import { errors } from './errors.mjs';
import { hooks } from './hooks.mjs';
import { idempotency } from './idempotency.mjs';
import { inlineExecution } from './inline-execution.mjs';
import { lineage } from './lineage.mjs';
import { nullByte } from './null-byte.mjs';

export function createTestSuite(pkgName: string) {
Expand All@@ -12,4 +13,5 @@ export function createTestSuite(pkgName: string) {
nullByte(pkgName);
errors(pkgName);
inlineExecution(pkgName);
lineage(pkgName);
}
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('^' + ".*" + ' feat: cross-run lineage via reserved run attributes by rchasman · Pull Request #2153 · 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
6 changes: 6 additions & 0 deletions .changeset/start-cross-run-lineage.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"@workflow/world": patch
---

Record cross-run lineage when `start()` is called from inside a workflow or step: the new run is tagged with `$parentRunId` (its direct parent) and inherits the parent's `$rootRunId`, so a daisy chain or fan-out of any depth groups under one root id.
9 changes: 9 additions & 0 deletions docs/content/docs/v5/observability/attributes.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,15 @@ export async function cleanupAttributes() {

Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.

## Reserved keys

When `start()` is called from inside a running workflow or step, the new run is automatically tagged with two reserved attributes:

- `$parentRunId`: the run that started it.
- `$rootRunId`: the root of the chain. It is inherited, so every run in a daisy chain or fan-out shares one root id.

Top-level runs (started outside any workflow or step) are not tagged.

## Viewing attributes

The run details panel in the observability UI shows the run's current attributes as key-value rows. Reserved `$`-prefixed keys are marked with a badge and sorted after user keys:
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ import {
import {
type Event,
getQueueTopicPrefix,
ROOT_RUN_ID_ATTRIBUTE,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand DownExpand Up@@ -242,6 +243,17 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
return true;
}

/**
* The lineage root of a loaded run: its `$rootRunId` attribute, or its own id
* when it is itself a root.
*/
function rootRunIdFrom(
attributes: Record<string, string> | undefined,
runId: string
): string {
return attributes?.[ROOT_RUN_ID_ATTRIBUTE] ?? runId;
}

/**
* Whether the run has any hook or wait that an out-of-band writer could
* append an event for between an inline step's `step_completed` write and
Expand DownExpand Up@@ -728,6 +740,7 @@ export function workflowEntrypoint(
workflowDeploymentId: bgRun.deploymentId,
workflowName,
workflowStartedAt: bgStartedAt,
rootRunId: rootRunIdFrom(bgRun.attributes, runId),
stepId: incomingStepId,
stepName: incomingStepName,
runSpecVersion: bgRun.specVersion,
Expand DownExpand Up@@ -1984,6 +1997,10 @@ export function workflowEntrypoint(
workflowRun.deploymentId,
workflowName,
workflowStartedAt,
rootRunId: rootRunIdFrom(
workflowRun.attributes,
runId
),
stepId: s.correlationId,
stepName: s.stepName,
runSpecVersion: workflowRun.specVersion,
Expand Down
156 changes: 156 additions & 0 deletions packages/core/src/runtime/start-lineage.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import {
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
} from '@workflow/world';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { contextStorage } from '../step/context-storage.js';
import { start } from './start.js';
import { setWorld } from './world.js';

vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() }));
vi.mock('../telemetry.js', () => ({
serializeTraceCarrier: vi.fn().mockResolvedValue({}),
trace: vi.fn((_name, fn) => fn(undefined)),
}));

/**
* Cross-run lineage: start() records reserved `$rootRunId` / `$parentRunId`
* attributes when it runs inside another run, so a daisy chain or fan-out
* groups under one root id. The lineage is a pure read of the ambient step
* context: the runtime fills both the parent run id and its root from the run
* it already has loaded, so start() never reads back the parent (`runs.get` is
* never called). A top-level start() records nothing.
*/
describe('start() cross-run lineage', () => {
let eventsCreate: ReturnType<typeof vi.fn>;
let runsGet: ReturnType<typeof vi.fn>;
let queue: ReturnType<typeof vi.fn>;

// The runtime requires the world to declare the current spec version; the
// per-run spec is driven separately via `opts.specVersion` where needed.
function useWorld() {
setWorld({
specVersion: SPEC_VERSION_CURRENT,
getDeploymentId: vi.fn().mockResolvedValue('deploy_123'),
events: { create: eventsCreate },
runs: { get: runsGet },
queue,
} as any);
}

beforeEach(() => {
eventsCreate = vi.fn().mockImplementation((runId) =>
Promise.resolve({
run: { runId: runId ?? 'wrun_x', status: 'pending' },
})
);
runsGet = vi.fn();
queue = vi.fn().mockResolvedValue(undefined);
useWorld();
});

afterEach(() => {
setWorld(undefined);
vi.clearAllMocks();
});

const wf = (id: string) =>
Object.assign(() => Promise.resolve('ok'), { workflowId: id });

/** Attributes seeded onto the run_created event for the first start() call. */
function seededAttributes(): Record<string, string> | undefined {
return eventsCreate.mock.calls[0]?.[1]?.eventData?.attributes;
}

/**
* Run `fn` as if executing inside a parent run's step context. Pass
* `rootRunId` to model the runtime having put the parent's root on the
* context (the wired path); omit it to exercise the anchor-to-parent default.
*/
function insideRun<T>(
parentRunId: string,
fn: () => Promise<T>,
rootRunId?: string
): Promise<T> {
return contextStorage.run(
{
stepMetadata: {
stepName: 'start',
stepId: 'step_1',
stepStartedAt: new Date(),
attempt: 1,
},
workflowMetadata: {
workflowName: 'parent',
workflowRunId: parentRunId,
workflowStartedAt: new Date(),
url: 'http://localhost:3000',
features: { encryption: false },
},
rootRunId,
ops: [],
} as any,
fn
);
}

it('records no lineage for a top-level start()', async () => {
await start(wf('test-workflow'), []);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});

it('inherits the root from the context with no read-back', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), []),
'wrun_root'
);

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
});
});

it('anchors the root to the parent when the context carries none', async () => {
await insideRun('wrun_parent', () => start(wf('child-workflow'), []));

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_parent',
$parentRunId: 'wrun_parent',
});
});

it('merges caller-provided attributes over the inferred lineage', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), [], { attributes: { tenant: 't1' } }),
'wrun_root'
);

const expected = {
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
tenant: 't1',
};
expect(seededAttributes()).toEqual(expected);
// Lineage must also ride the resilient-start queue input, not just the
// run_created event, so both creation paths carry it.
expect(queue.mock.calls[0]?.[1]?.runInput?.attributes).toEqual(expected);
});

it('records no lineage when the run predates attribute support', async () => {
await insideRun('wrun_parent', () =>
start(wf('child-workflow'), [], {
specVersion: SPEC_VERSION_SUPPORTS_ATTRIBUTES - 1,
})
);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});
});
48 changes: 42 additions & 6 deletions packages/core/src/runtime/start.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import { workflowDisplayName } from '@workflow/utils/parse-name';
import type { WorkflowInvokePayload, World } from '@workflow/world';
import {
isLegacySpecVersion,
PARENT_RUN_ID_ATTRIBUTE,
ROOT_RUN_ID_ATTRIBUTE,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand All@@ -19,6 +21,7 @@ import {
dehydrateWorkflowArguments,
SerializationFormat,
} from '../serialization.js';
import { contextStorage } from '../step/context-storage.js';
import * as Attribute from '../telemetry/semantic-conventions.js';
import { serializeTraceCarrier, trace } from '../telemetry.js';
import { version as workflowCoreVersion } from '../version.js';
Expand All@@ -42,6 +45,28 @@ const CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS = 2_000;
/** ULID generator for client-side runId generation */
const ulid = monotonicFactory();

/**
* Cross-run lineage for a run being started from inside another run.
*
* The ambient step context carries the parent run id and the root of its
* lineage; the runtime fills both from the run it already has loaded, so this
* is a pure context read with no I/O. The new run records `$parentRunId` (the
* edge) and inherits the parent's `$rootRunId` (the parent itself when it is a
* root), so a daisy chain or fan-out of any depth groups under one root id.
* Returns `undefined` for a top-level `start()`, which has no context, so
* standalone runs carry no lineage.
*/
function resolveLineageAttributes(): Record<string, string> | undefined {
const store = contextStorage.getStore();
const parentRunId = store?.workflowMetadata?.workflowRunId;
if (!parentRunId) return undefined;

return {
[ROOT_RUN_ID_ATTRIBUTE]: store.rootRunId ?? parentRunId,
[PARENT_RUN_ID_ATTRIBUTE]: parentRunId,
};
}

// `deploymentId: 'latest'` is a no-op in Worlds without atomic deployments.
// The warning that explains this only needs to fire once per process: a
// workflow that hardcodes 'latest' for its Vercel deployment would otherwise
Expand DownExpand Up@@ -360,13 +385,24 @@ export async function start<TArgs extends unknown[], TResult>(
changes.map(({ key, value }) => [key, value as string])
);
}
// Seed payload shared by run_created and the resilient-start queue
// input. The flag rides along so server-side validation matches the
// client-side check above on both paths.
const attributeSeed = attributes

// Cross-run lineage: the reserved keys ride on the run's existing
// attributes, so they add no extra write. Caller attributes are spread
// last, so a caller with allowReservedAttributes can deliberately
// re-parent.
const lineage =
specVersion >= SPEC_VERSION_SUPPORTS_ATTRIBUTES
? resolveLineageAttributes()
: undefined;
const runAttributes = lineage
? { ...lineage, ...attributes }
: attributes;

// Shared by the run_created event and the resilient-start queue input.
const attributeSeed = runAttributes
? {
attributes,
...(allowReservedAttributes
attributes: runAttributes,
...(allowReservedAttributes || lineage != null
? { allowReservedAttributes: true as const }
: {}),
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runtime/step-executor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,8 @@ export interface StepExecutorParams {
workflowDeploymentId?: string;
workflowName: string;
workflowStartedAt: number;
/** Root run id of this run's lineage, carried into the step context. */
rootRunId?: string;
stepId: string;
stepName: string;
encryptionKey?: CryptoKey;
Expand DownExpand Up@@ -690,6 +692,7 @@ export async function executeStep(
features: { encryption: !!encryptionKey },
},
workflowDeploymentId: params.workflowDeploymentId,
rootRunId: params.rootRunId,
ops,
preCompletionOps,
closureVars: hydratedInput.closureVars,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/step/context-storage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ export type StepContext = {
workflowMetadata: WorkflowMetadata;
/** Deployment that owns the current workflow run, used for forwarded streams. */
workflowDeploymentId?: string;
/**
* Root run id of the current run's lineage (`$rootRunId`), so a nested
* `start()` inherits it without reloading the parent. Set by the runtime from
* the run it already has loaded.
*/
rootRunId?: string;
ops: Promise<void>[];
/**
* Operations that MUST be durably committed before the step's
Expand Down
2 changes: 2 additions & 0 deletions packages/world-testing/src/index.mts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import { errors } from './errors.mjs';
import { hooks } from './hooks.mjs';
import { idempotency } from './idempotency.mjs';
import { inlineExecution } from './inline-execution.mjs';
import { lineage } from './lineage.mjs';
import { nullByte } from './null-byte.mjs';

export function createTestSuite(pkgName: string) {
Expand All@@ -12,4 +13,5 @@ export function createTestSuite(pkgName: string) {
nullByte(pkgName);
errors(pkgName);
inlineExecution(pkgName);
lineage(pkgName);
}
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('^' + ".*" + ' feat: cross-run lineage via reserved run attributes by rchasman · Pull Request #2153 · 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
6 changes: 6 additions & 0 deletions .changeset/start-cross-run-lineage.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"@workflow/world": patch
---

Record cross-run lineage when `start()` is called from inside a workflow or step: the new run is tagged with `$parentRunId` (its direct parent) and inherits the parent's `$rootRunId`, so a daisy chain or fan-out of any depth groups under one root id.
9 changes: 9 additions & 0 deletions docs/content/docs/v5/observability/attributes.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,15 @@ export async function cleanupAttributes() {

Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.

## Reserved keys

When `start()` is called from inside a running workflow or step, the new run is automatically tagged with two reserved attributes:

- `$parentRunId`: the run that started it.
- `$rootRunId`: the root of the chain. It is inherited, so every run in a daisy chain or fan-out shares one root id.

Top-level runs (started outside any workflow or step) are not tagged.

## Viewing attributes

The run details panel in the observability UI shows the run's current attributes as key-value rows. Reserved `$`-prefixed keys are marked with a badge and sorted after user keys:
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ import {
import {
type Event,
getQueueTopicPrefix,
ROOT_RUN_ID_ATTRIBUTE,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand DownExpand Up@@ -242,6 +243,17 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
return true;
}

/**
* The lineage root of a loaded run: its `$rootRunId` attribute, or its own id
* when it is itself a root.
*/
function rootRunIdFrom(
attributes: Record<string, string> | undefined,
runId: string
): string {
return attributes?.[ROOT_RUN_ID_ATTRIBUTE] ?? runId;
}

/**
* Whether the run has any hook or wait that an out-of-band writer could
* append an event for between an inline step's `step_completed` write and
Expand DownExpand Up@@ -728,6 +740,7 @@ export function workflowEntrypoint(
workflowDeploymentId: bgRun.deploymentId,
workflowName,
workflowStartedAt: bgStartedAt,
rootRunId: rootRunIdFrom(bgRun.attributes, runId),
stepId: incomingStepId,
stepName: incomingStepName,
runSpecVersion: bgRun.specVersion,
Expand DownExpand Up@@ -1984,6 +1997,10 @@ export function workflowEntrypoint(
workflowRun.deploymentId,
workflowName,
workflowStartedAt,
rootRunId: rootRunIdFrom(
workflowRun.attributes,
runId
),
stepId: s.correlationId,
stepName: s.stepName,
runSpecVersion: workflowRun.specVersion,
Expand Down
156 changes: 156 additions & 0 deletions packages/core/src/runtime/start-lineage.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import {
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
} from '@workflow/world';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { contextStorage } from '../step/context-storage.js';
import { start } from './start.js';
import { setWorld } from './world.js';

vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() }));
vi.mock('../telemetry.js', () => ({
serializeTraceCarrier: vi.fn().mockResolvedValue({}),
trace: vi.fn((_name, fn) => fn(undefined)),
}));

/**
* Cross-run lineage: start() records reserved `$rootRunId` / `$parentRunId`
* attributes when it runs inside another run, so a daisy chain or fan-out
* groups under one root id. The lineage is a pure read of the ambient step
* context: the runtime fills both the parent run id and its root from the run
* it already has loaded, so start() never reads back the parent (`runs.get` is
* never called). A top-level start() records nothing.
*/
describe('start() cross-run lineage', () => {
let eventsCreate: ReturnType<typeof vi.fn>;
let runsGet: ReturnType<typeof vi.fn>;
let queue: ReturnType<typeof vi.fn>;

// The runtime requires the world to declare the current spec version; the
// per-run spec is driven separately via `opts.specVersion` where needed.
function useWorld() {
setWorld({
specVersion: SPEC_VERSION_CURRENT,
getDeploymentId: vi.fn().mockResolvedValue('deploy_123'),
events: { create: eventsCreate },
runs: { get: runsGet },
queue,
} as any);
}

beforeEach(() => {
eventsCreate = vi.fn().mockImplementation((runId) =>
Promise.resolve({
run: { runId: runId ?? 'wrun_x', status: 'pending' },
})
);
runsGet = vi.fn();
queue = vi.fn().mockResolvedValue(undefined);
useWorld();
});

afterEach(() => {
setWorld(undefined);
vi.clearAllMocks();
});

const wf = (id: string) =>
Object.assign(() => Promise.resolve('ok'), { workflowId: id });

/** Attributes seeded onto the run_created event for the first start() call. */
function seededAttributes(): Record<string, string> | undefined {
return eventsCreate.mock.calls[0]?.[1]?.eventData?.attributes;
}

/**
* Run `fn` as if executing inside a parent run's step context. Pass
* `rootRunId` to model the runtime having put the parent's root on the
* context (the wired path); omit it to exercise the anchor-to-parent default.
*/
function insideRun<T>(
parentRunId: string,
fn: () => Promise<T>,
rootRunId?: string
): Promise<T> {
return contextStorage.run(
{
stepMetadata: {
stepName: 'start',
stepId: 'step_1',
stepStartedAt: new Date(),
attempt: 1,
},
workflowMetadata: {
workflowName: 'parent',
workflowRunId: parentRunId,
workflowStartedAt: new Date(),
url: 'http://localhost:3000',
features: { encryption: false },
},
rootRunId,
ops: [],
} as any,
fn
);
}

it('records no lineage for a top-level start()', async () => {
await start(wf('test-workflow'), []);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});

it('inherits the root from the context with no read-back', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), []),
'wrun_root'
);

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
});
});

it('anchors the root to the parent when the context carries none', async () => {
await insideRun('wrun_parent', () => start(wf('child-workflow'), []));

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_parent',
$parentRunId: 'wrun_parent',
});
});

it('merges caller-provided attributes over the inferred lineage', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), [], { attributes: { tenant: 't1' } }),
'wrun_root'
);

const expected = {
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
tenant: 't1',
};
expect(seededAttributes()).toEqual(expected);
// Lineage must also ride the resilient-start queue input, not just the
// run_created event, so both creation paths carry it.
expect(queue.mock.calls[0]?.[1]?.runInput?.attributes).toEqual(expected);
});

it('records no lineage when the run predates attribute support', async () => {
await insideRun('wrun_parent', () =>
start(wf('child-workflow'), [], {
specVersion: SPEC_VERSION_SUPPORTS_ATTRIBUTES - 1,
})
);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});
});
48 changes: 42 additions & 6 deletions packages/core/src/runtime/start.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import { workflowDisplayName } from '@workflow/utils/parse-name';
import type { WorkflowInvokePayload, World } from '@workflow/world';
import {
isLegacySpecVersion,
PARENT_RUN_ID_ATTRIBUTE,
ROOT_RUN_ID_ATTRIBUTE,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand All@@ -19,6 +21,7 @@ import {
dehydrateWorkflowArguments,
SerializationFormat,
} from '../serialization.js';
import { contextStorage } from '../step/context-storage.js';
import * as Attribute from '../telemetry/semantic-conventions.js';
import { serializeTraceCarrier, trace } from '../telemetry.js';
import { version as workflowCoreVersion } from '../version.js';
Expand All@@ -42,6 +45,28 @@ const CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS = 2_000;
/** ULID generator for client-side runId generation */
const ulid = monotonicFactory();

/**
* Cross-run lineage for a run being started from inside another run.
*
* The ambient step context carries the parent run id and the root of its
* lineage; the runtime fills both from the run it already has loaded, so this
* is a pure context read with no I/O. The new run records `$parentRunId` (the
* edge) and inherits the parent's `$rootRunId` (the parent itself when it is a
* root), so a daisy chain or fan-out of any depth groups under one root id.
* Returns `undefined` for a top-level `start()`, which has no context, so
* standalone runs carry no lineage.
*/
function resolveLineageAttributes(): Record<string, string> | undefined {
const store = contextStorage.getStore();
const parentRunId = store?.workflowMetadata?.workflowRunId;
if (!parentRunId) return undefined;

return {
[ROOT_RUN_ID_ATTRIBUTE]: store.rootRunId ?? parentRunId,
[PARENT_RUN_ID_ATTRIBUTE]: parentRunId,
};
}

// `deploymentId: 'latest'` is a no-op in Worlds without atomic deployments.
// The warning that explains this only needs to fire once per process: a
// workflow that hardcodes 'latest' for its Vercel deployment would otherwise
Expand DownExpand Up@@ -360,13 +385,24 @@ export async function start<TArgs extends unknown[], TResult>(
changes.map(({ key, value }) => [key, value as string])
);
}
// Seed payload shared by run_created and the resilient-start queue
// input. The flag rides along so server-side validation matches the
// client-side check above on both paths.
const attributeSeed = attributes

// Cross-run lineage: the reserved keys ride on the run's existing
// attributes, so they add no extra write. Caller attributes are spread
// last, so a caller with allowReservedAttributes can deliberately
// re-parent.
const lineage =
specVersion >= SPEC_VERSION_SUPPORTS_ATTRIBUTES
? resolveLineageAttributes()
: undefined;
const runAttributes = lineage
? { ...lineage, ...attributes }
: attributes;

// Shared by the run_created event and the resilient-start queue input.
const attributeSeed = runAttributes
? {
attributes,
...(allowReservedAttributes
attributes: runAttributes,
...(allowReservedAttributes || lineage != null
? { allowReservedAttributes: true as const }
: {}),
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runtime/step-executor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,8 @@ export interface StepExecutorParams {
workflowDeploymentId?: string;
workflowName: string;
workflowStartedAt: number;
/** Root run id of this run's lineage, carried into the step context. */
rootRunId?: string;
stepId: string;
stepName: string;
encryptionKey?: CryptoKey;
Expand DownExpand Up@@ -690,6 +692,7 @@ export async function executeStep(
features: { encryption: !!encryptionKey },
},
workflowDeploymentId: params.workflowDeploymentId,
rootRunId: params.rootRunId,
ops,
preCompletionOps,
closureVars: hydratedInput.closureVars,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/step/context-storage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ export type StepContext = {
workflowMetadata: WorkflowMetadata;
/** Deployment that owns the current workflow run, used for forwarded streams. */
workflowDeploymentId?: string;
/**
* Root run id of the current run's lineage (`$rootRunId`), so a nested
* `start()` inherits it without reloading the parent. Set by the runtime from
* the run it already has loaded.
*/
rootRunId?: string;
ops: Promise<void>[];
/**
* Operations that MUST be durably committed before the step's
Expand Down
2 changes: 2 additions & 0 deletions packages/world-testing/src/index.mts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import { errors } from './errors.mjs';
import { hooks } from './hooks.mjs';
import { idempotency } from './idempotency.mjs';
import { inlineExecution } from './inline-execution.mjs';
import { lineage } from './lineage.mjs';
import { nullByte } from './null-byte.mjs';

export function createTestSuite(pkgName: string) {
Expand All@@ -12,4 +13,5 @@ export function createTestSuite(pkgName: string) {
nullByte(pkgName);
errors(pkgName);
inlineExecution(pkgName);
lineage(pkgName);
}
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" + ' feat: cross-run lineage via reserved run attributes by rchasman · Pull Request #2153 · 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
6 changes: 6 additions & 0 deletions .changeset/start-cross-run-lineage.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"@workflow/world": patch
---

Record cross-run lineage when `start()` is called from inside a workflow or step: the new run is tagged with `$parentRunId` (its direct parent) and inherits the parent's `$rootRunId`, so a daisy chain or fan-out of any depth groups under one root id.
9 changes: 9 additions & 0 deletions docs/content/docs/v5/observability/attributes.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,15 @@ export async function cleanupAttributes() {

Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.

## Reserved keys

When `start()` is called from inside a running workflow or step, the new run is automatically tagged with two reserved attributes:

- `$parentRunId`: the run that started it.
- `$rootRunId`: the root of the chain. It is inherited, so every run in a daisy chain or fan-out shares one root id.

Top-level runs (started outside any workflow or step) are not tagged.

## Viewing attributes

The run details panel in the observability UI shows the run's current attributes as key-value rows. Reserved `$`-prefixed keys are marked with a badge and sorted after user keys:
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ import {
import {
type Event,
getQueueTopicPrefix,
ROOT_RUN_ID_ATTRIBUTE,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand DownExpand Up@@ -242,6 +243,17 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
return true;
}

/**
* The lineage root of a loaded run: its `$rootRunId` attribute, or its own id
* when it is itself a root.
*/
function rootRunIdFrom(
attributes: Record<string, string> | undefined,
runId: string
): string {
return attributes?.[ROOT_RUN_ID_ATTRIBUTE] ?? runId;
}

/**
* Whether the run has any hook or wait that an out-of-band writer could
* append an event for between an inline step's `step_completed` write and
Expand DownExpand Up@@ -728,6 +740,7 @@ export function workflowEntrypoint(
workflowDeploymentId: bgRun.deploymentId,
workflowName,
workflowStartedAt: bgStartedAt,
rootRunId: rootRunIdFrom(bgRun.attributes, runId),
stepId: incomingStepId,
stepName: incomingStepName,
runSpecVersion: bgRun.specVersion,
Expand DownExpand Up@@ -1984,6 +1997,10 @@ export function workflowEntrypoint(
workflowRun.deploymentId,
workflowName,
workflowStartedAt,
rootRunId: rootRunIdFrom(
workflowRun.attributes,
runId
),
stepId: s.correlationId,
stepName: s.stepName,
runSpecVersion: workflowRun.specVersion,
Expand Down
156 changes: 156 additions & 0 deletions packages/core/src/runtime/start-lineage.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import {
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
} from '@workflow/world';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { contextStorage } from '../step/context-storage.js';
import { start } from './start.js';
import { setWorld } from './world.js';

vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() }));
vi.mock('../telemetry.js', () => ({
serializeTraceCarrier: vi.fn().mockResolvedValue({}),
trace: vi.fn((_name, fn) => fn(undefined)),
}));

/**
* Cross-run lineage: start() records reserved `$rootRunId` / `$parentRunId`
* attributes when it runs inside another run, so a daisy chain or fan-out
* groups under one root id. The lineage is a pure read of the ambient step
* context: the runtime fills both the parent run id and its root from the run
* it already has loaded, so start() never reads back the parent (`runs.get` is
* never called). A top-level start() records nothing.
*/
describe('start() cross-run lineage', () => {
let eventsCreate: ReturnType<typeof vi.fn>;
let runsGet: ReturnType<typeof vi.fn>;
let queue: ReturnType<typeof vi.fn>;

// The runtime requires the world to declare the current spec version; the
// per-run spec is driven separately via `opts.specVersion` where needed.
function useWorld() {
setWorld({
specVersion: SPEC_VERSION_CURRENT,
getDeploymentId: vi.fn().mockResolvedValue('deploy_123'),
events: { create: eventsCreate },
runs: { get: runsGet },
queue,
} as any);
}

beforeEach(() => {
eventsCreate = vi.fn().mockImplementation((runId) =>
Promise.resolve({
run: { runId: runId ?? 'wrun_x', status: 'pending' },
})
);
runsGet = vi.fn();
queue = vi.fn().mockResolvedValue(undefined);
useWorld();
});

afterEach(() => {
setWorld(undefined);
vi.clearAllMocks();
});

const wf = (id: string) =>
Object.assign(() => Promise.resolve('ok'), { workflowId: id });

/** Attributes seeded onto the run_created event for the first start() call. */
function seededAttributes(): Record<string, string> | undefined {
return eventsCreate.mock.calls[0]?.[1]?.eventData?.attributes;
}

/**
* Run `fn` as if executing inside a parent run's step context. Pass
* `rootRunId` to model the runtime having put the parent's root on the
* context (the wired path); omit it to exercise the anchor-to-parent default.
*/
function insideRun<T>(
parentRunId: string,
fn: () => Promise<T>,
rootRunId?: string
): Promise<T> {
return contextStorage.run(
{
stepMetadata: {
stepName: 'start',
stepId: 'step_1',
stepStartedAt: new Date(),
attempt: 1,
},
workflowMetadata: {
workflowName: 'parent',
workflowRunId: parentRunId,
workflowStartedAt: new Date(),
url: 'http://localhost:3000',
features: { encryption: false },
},
rootRunId,
ops: [],
} as any,
fn
);
}

it('records no lineage for a top-level start()', async () => {
await start(wf('test-workflow'), []);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});

it('inherits the root from the context with no read-back', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), []),
'wrun_root'
);

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
});
});

it('anchors the root to the parent when the context carries none', async () => {
await insideRun('wrun_parent', () => start(wf('child-workflow'), []));

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_parent',
$parentRunId: 'wrun_parent',
});
});

it('merges caller-provided attributes over the inferred lineage', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), [], { attributes: { tenant: 't1' } }),
'wrun_root'
);

const expected = {
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
tenant: 't1',
};
expect(seededAttributes()).toEqual(expected);
// Lineage must also ride the resilient-start queue input, not just the
// run_created event, so both creation paths carry it.
expect(queue.mock.calls[0]?.[1]?.runInput?.attributes).toEqual(expected);
});

it('records no lineage when the run predates attribute support', async () => {
await insideRun('wrun_parent', () =>
start(wf('child-workflow'), [], {
specVersion: SPEC_VERSION_SUPPORTS_ATTRIBUTES - 1,
})
);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});
});
48 changes: 42 additions & 6 deletions packages/core/src/runtime/start.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import { workflowDisplayName } from '@workflow/utils/parse-name';
import type { WorkflowInvokePayload, World } from '@workflow/world';
import {
isLegacySpecVersion,
PARENT_RUN_ID_ATTRIBUTE,
ROOT_RUN_ID_ATTRIBUTE,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand All@@ -19,6 +21,7 @@ import {
dehydrateWorkflowArguments,
SerializationFormat,
} from '../serialization.js';
import { contextStorage } from '../step/context-storage.js';
import * as Attribute from '../telemetry/semantic-conventions.js';
import { serializeTraceCarrier, trace } from '../telemetry.js';
import { version as workflowCoreVersion } from '../version.js';
Expand All@@ -42,6 +45,28 @@ const CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS = 2_000;
/** ULID generator for client-side runId generation */
const ulid = monotonicFactory();

/**
* Cross-run lineage for a run being started from inside another run.
*
* The ambient step context carries the parent run id and the root of its
* lineage; the runtime fills both from the run it already has loaded, so this
* is a pure context read with no I/O. The new run records `$parentRunId` (the
* edge) and inherits the parent's `$rootRunId` (the parent itself when it is a
* root), so a daisy chain or fan-out of any depth groups under one root id.
* Returns `undefined` for a top-level `start()`, which has no context, so
* standalone runs carry no lineage.
*/
function resolveLineageAttributes(): Record<string, string> | undefined {
const store = contextStorage.getStore();
const parentRunId = store?.workflowMetadata?.workflowRunId;
if (!parentRunId) return undefined;

return {
[ROOT_RUN_ID_ATTRIBUTE]: store.rootRunId ?? parentRunId,
[PARENT_RUN_ID_ATTRIBUTE]: parentRunId,
};
}

// `deploymentId: 'latest'` is a no-op in Worlds without atomic deployments.
// The warning that explains this only needs to fire once per process: a
// workflow that hardcodes 'latest' for its Vercel deployment would otherwise
Expand DownExpand Up@@ -360,13 +385,24 @@ export async function start<TArgs extends unknown[], TResult>(
changes.map(({ key, value }) => [key, value as string])
);
}
// Seed payload shared by run_created and the resilient-start queue
// input. The flag rides along so server-side validation matches the
// client-side check above on both paths.
const attributeSeed = attributes

// Cross-run lineage: the reserved keys ride on the run's existing
// attributes, so they add no extra write. Caller attributes are spread
// last, so a caller with allowReservedAttributes can deliberately
// re-parent.
const lineage =
specVersion >= SPEC_VERSION_SUPPORTS_ATTRIBUTES
? resolveLineageAttributes()
: undefined;
const runAttributes = lineage
? { ...lineage, ...attributes }
: attributes;

// Shared by the run_created event and the resilient-start queue input.
const attributeSeed = runAttributes
? {
attributes,
...(allowReservedAttributes
attributes: runAttributes,
...(allowReservedAttributes || lineage != null
? { allowReservedAttributes: true as const }
: {}),
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runtime/step-executor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,8 @@ export interface StepExecutorParams {
workflowDeploymentId?: string;
workflowName: string;
workflowStartedAt: number;
/** Root run id of this run's lineage, carried into the step context. */
rootRunId?: string;
stepId: string;
stepName: string;
encryptionKey?: CryptoKey;
Expand DownExpand Up@@ -690,6 +692,7 @@ export async function executeStep(
features: { encryption: !!encryptionKey },
},
workflowDeploymentId: params.workflowDeploymentId,
rootRunId: params.rootRunId,
ops,
preCompletionOps,
closureVars: hydratedInput.closureVars,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/step/context-storage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ export type StepContext = {
workflowMetadata: WorkflowMetadata;
/** Deployment that owns the current workflow run, used for forwarded streams. */
workflowDeploymentId?: string;
/**
* Root run id of the current run's lineage (`$rootRunId`), so a nested
* `start()` inherits it without reloading the parent. Set by the runtime from
* the run it already has loaded.
*/
rootRunId?: string;
ops: Promise<void>[];
/**
* Operations that MUST be durably committed before the step's
Expand Down
2 changes: 2 additions & 0 deletions packages/world-testing/src/index.mts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import { errors } from './errors.mjs';
import { hooks } from './hooks.mjs';
import { idempotency } from './idempotency.mjs';
import { inlineExecution } from './inline-execution.mjs';
import { lineage } from './lineage.mjs';
import { nullByte } from './null-byte.mjs';

export function createTestSuite(pkgName: string) {
Expand All@@ -12,4 +13,5 @@ export function createTestSuite(pkgName: string) {
nullByte(pkgName);
errors(pkgName);
inlineExecution(pkgName);
lineage(pkgName);
}
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('^' + ".*" + ' feat: cross-run lineage via reserved run attributes by rchasman · Pull Request #2153 · 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
6 changes: 6 additions & 0 deletions .changeset/start-cross-run-lineage.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"@workflow/world": patch
---

Record cross-run lineage when `start()` is called from inside a workflow or step: the new run is tagged with `$parentRunId` (its direct parent) and inherits the parent's `$rootRunId`, so a daisy chain or fan-out of any depth groups under one root id.
9 changes: 9 additions & 0 deletions docs/content/docs/v5/observability/attributes.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,15 @@ export async function cleanupAttributes() {

Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.

## Reserved keys

When `start()` is called from inside a running workflow or step, the new run is automatically tagged with two reserved attributes:

- `$parentRunId`: the run that started it.
- `$rootRunId`: the root of the chain. It is inherited, so every run in a daisy chain or fan-out shares one root id.

Top-level runs (started outside any workflow or step) are not tagged.

## Viewing attributes

The run details panel in the observability UI shows the run's current attributes as key-value rows. Reserved `$`-prefixed keys are marked with a badge and sorted after user keys:
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ import {
import {
type Event,
getQueueTopicPrefix,
ROOT_RUN_ID_ATTRIBUTE,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand DownExpand Up@@ -242,6 +243,17 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
return true;
}

/**
* The lineage root of a loaded run: its `$rootRunId` attribute, or its own id
* when it is itself a root.
*/
function rootRunIdFrom(
attributes: Record<string, string> | undefined,
runId: string
): string {
return attributes?.[ROOT_RUN_ID_ATTRIBUTE] ?? runId;
}

/**
* Whether the run has any hook or wait that an out-of-band writer could
* append an event for between an inline step's `step_completed` write and
Expand DownExpand Up@@ -728,6 +740,7 @@ export function workflowEntrypoint(
workflowDeploymentId: bgRun.deploymentId,
workflowName,
workflowStartedAt: bgStartedAt,
rootRunId: rootRunIdFrom(bgRun.attributes, runId),
stepId: incomingStepId,
stepName: incomingStepName,
runSpecVersion: bgRun.specVersion,
Expand DownExpand Up@@ -1984,6 +1997,10 @@ export function workflowEntrypoint(
workflowRun.deploymentId,
workflowName,
workflowStartedAt,
rootRunId: rootRunIdFrom(
workflowRun.attributes,
runId
),
stepId: s.correlationId,
stepName: s.stepName,
runSpecVersion: workflowRun.specVersion,
Expand Down
156 changes: 156 additions & 0 deletions packages/core/src/runtime/start-lineage.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import {
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
} from '@workflow/world';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { contextStorage } from '../step/context-storage.js';
import { start } from './start.js';
import { setWorld } from './world.js';

vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() }));
vi.mock('../telemetry.js', () => ({
serializeTraceCarrier: vi.fn().mockResolvedValue({}),
trace: vi.fn((_name, fn) => fn(undefined)),
}));

/**
* Cross-run lineage: start() records reserved `$rootRunId` / `$parentRunId`
* attributes when it runs inside another run, so a daisy chain or fan-out
* groups under one root id. The lineage is a pure read of the ambient step
* context: the runtime fills both the parent run id and its root from the run
* it already has loaded, so start() never reads back the parent (`runs.get` is
* never called). A top-level start() records nothing.
*/
describe('start() cross-run lineage', () => {
let eventsCreate: ReturnType<typeof vi.fn>;
let runsGet: ReturnType<typeof vi.fn>;
let queue: ReturnType<typeof vi.fn>;

// The runtime requires the world to declare the current spec version; the
// per-run spec is driven separately via `opts.specVersion` where needed.
function useWorld() {
setWorld({
specVersion: SPEC_VERSION_CURRENT,
getDeploymentId: vi.fn().mockResolvedValue('deploy_123'),
events: { create: eventsCreate },
runs: { get: runsGet },
queue,
} as any);
}

beforeEach(() => {
eventsCreate = vi.fn().mockImplementation((runId) =>
Promise.resolve({
run: { runId: runId ?? 'wrun_x', status: 'pending' },
})
);
runsGet = vi.fn();
queue = vi.fn().mockResolvedValue(undefined);
useWorld();
});

afterEach(() => {
setWorld(undefined);
vi.clearAllMocks();
});

const wf = (id: string) =>
Object.assign(() => Promise.resolve('ok'), { workflowId: id });

/** Attributes seeded onto the run_created event for the first start() call. */
function seededAttributes(): Record<string, string> | undefined {
return eventsCreate.mock.calls[0]?.[1]?.eventData?.attributes;
}

/**
* Run `fn` as if executing inside a parent run's step context. Pass
* `rootRunId` to model the runtime having put the parent's root on the
* context (the wired path); omit it to exercise the anchor-to-parent default.
*/
function insideRun<T>(
parentRunId: string,
fn: () => Promise<T>,
rootRunId?: string
): Promise<T> {
return contextStorage.run(
{
stepMetadata: {
stepName: 'start',
stepId: 'step_1',
stepStartedAt: new Date(),
attempt: 1,
},
workflowMetadata: {
workflowName: 'parent',
workflowRunId: parentRunId,
workflowStartedAt: new Date(),
url: 'http://localhost:3000',
features: { encryption: false },
},
rootRunId,
ops: [],
} as any,
fn
);
}

it('records no lineage for a top-level start()', async () => {
await start(wf('test-workflow'), []);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});

it('inherits the root from the context with no read-back', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), []),
'wrun_root'
);

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
});
});

it('anchors the root to the parent when the context carries none', async () => {
await insideRun('wrun_parent', () => start(wf('child-workflow'), []));

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_parent',
$parentRunId: 'wrun_parent',
});
});

it('merges caller-provided attributes over the inferred lineage', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), [], { attributes: { tenant: 't1' } }),
'wrun_root'
);

const expected = {
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
tenant: 't1',
};
expect(seededAttributes()).toEqual(expected);
// Lineage must also ride the resilient-start queue input, not just the
// run_created event, so both creation paths carry it.
expect(queue.mock.calls[0]?.[1]?.runInput?.attributes).toEqual(expected);
});

it('records no lineage when the run predates attribute support', async () => {
await insideRun('wrun_parent', () =>
start(wf('child-workflow'), [], {
specVersion: SPEC_VERSION_SUPPORTS_ATTRIBUTES - 1,
})
);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});
});
48 changes: 42 additions & 6 deletions packages/core/src/runtime/start.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import { workflowDisplayName } from '@workflow/utils/parse-name';
import type { WorkflowInvokePayload, World } from '@workflow/world';
import {
isLegacySpecVersion,
PARENT_RUN_ID_ATTRIBUTE,
ROOT_RUN_ID_ATTRIBUTE,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand All@@ -19,6 +21,7 @@ import {
dehydrateWorkflowArguments,
SerializationFormat,
} from '../serialization.js';
import { contextStorage } from '../step/context-storage.js';
import * as Attribute from '../telemetry/semantic-conventions.js';
import { serializeTraceCarrier, trace } from '../telemetry.js';
import { version as workflowCoreVersion } from '../version.js';
Expand All@@ -42,6 +45,28 @@ const CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS = 2_000;
/** ULID generator for client-side runId generation */
const ulid = monotonicFactory();

/**
* Cross-run lineage for a run being started from inside another run.
*
* The ambient step context carries the parent run id and the root of its
* lineage; the runtime fills both from the run it already has loaded, so this
* is a pure context read with no I/O. The new run records `$parentRunId` (the
* edge) and inherits the parent's `$rootRunId` (the parent itself when it is a
* root), so a daisy chain or fan-out of any depth groups under one root id.
* Returns `undefined` for a top-level `start()`, which has no context, so
* standalone runs carry no lineage.
*/
function resolveLineageAttributes(): Record<string, string> | undefined {
const store = contextStorage.getStore();
const parentRunId = store?.workflowMetadata?.workflowRunId;
if (!parentRunId) return undefined;

return {
[ROOT_RUN_ID_ATTRIBUTE]: store.rootRunId ?? parentRunId,
[PARENT_RUN_ID_ATTRIBUTE]: parentRunId,
};
}

// `deploymentId: 'latest'` is a no-op in Worlds without atomic deployments.
// The warning that explains this only needs to fire once per process: a
// workflow that hardcodes 'latest' for its Vercel deployment would otherwise
Expand DownExpand Up@@ -360,13 +385,24 @@ export async function start<TArgs extends unknown[], TResult>(
changes.map(({ key, value }) => [key, value as string])
);
}
// Seed payload shared by run_created and the resilient-start queue
// input. The flag rides along so server-side validation matches the
// client-side check above on both paths.
const attributeSeed = attributes

// Cross-run lineage: the reserved keys ride on the run's existing
// attributes, so they add no extra write. Caller attributes are spread
// last, so a caller with allowReservedAttributes can deliberately
// re-parent.
const lineage =
specVersion >= SPEC_VERSION_SUPPORTS_ATTRIBUTES
? resolveLineageAttributes()
: undefined;
const runAttributes = lineage
? { ...lineage, ...attributes }
: attributes;

// Shared by the run_created event and the resilient-start queue input.
const attributeSeed = runAttributes
? {
attributes,
...(allowReservedAttributes
attributes: runAttributes,
...(allowReservedAttributes || lineage != null
? { allowReservedAttributes: true as const }
: {}),
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runtime/step-executor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,8 @@ export interface StepExecutorParams {
workflowDeploymentId?: string;
workflowName: string;
workflowStartedAt: number;
/** Root run id of this run's lineage, carried into the step context. */
rootRunId?: string;
stepId: string;
stepName: string;
encryptionKey?: CryptoKey;
Expand DownExpand Up@@ -690,6 +692,7 @@ export async function executeStep(
features: { encryption: !!encryptionKey },
},
workflowDeploymentId: params.workflowDeploymentId,
rootRunId: params.rootRunId,
ops,
preCompletionOps,
closureVars: hydratedInput.closureVars,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/step/context-storage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ export type StepContext = {
workflowMetadata: WorkflowMetadata;
/** Deployment that owns the current workflow run, used for forwarded streams. */
workflowDeploymentId?: string;
/**
* Root run id of the current run's lineage (`$rootRunId`), so a nested
* `start()` inherits it without reloading the parent. Set by the runtime from
* the run it already has loaded.
*/
rootRunId?: string;
ops: Promise<void>[];
/**
* Operations that MUST be durably committed before the step's
Expand Down
2 changes: 2 additions & 0 deletions packages/world-testing/src/index.mts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import { errors } from './errors.mjs';
import { hooks } from './hooks.mjs';
import { idempotency } from './idempotency.mjs';
import { inlineExecution } from './inline-execution.mjs';
import { lineage } from './lineage.mjs';
import { nullByte } from './null-byte.mjs';

export function createTestSuite(pkgName: string) {
Expand All@@ -12,4 +13,5 @@ export function createTestSuite(pkgName: string) {
nullByte(pkgName);
errors(pkgName);
inlineExecution(pkgName);
lineage(pkgName);
}
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('^' + ".*" + ' feat: cross-run lineage via reserved run attributes by rchasman · Pull Request #2153 · 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
6 changes: 6 additions & 0 deletions .changeset/start-cross-run-lineage.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"@workflow/world": patch
---

Record cross-run lineage when `start()` is called from inside a workflow or step: the new run is tagged with `$parentRunId` (its direct parent) and inherits the parent's `$rootRunId`, so a daisy chain or fan-out of any depth groups under one root id.
9 changes: 9 additions & 0 deletions docs/content/docs/v5/observability/attributes.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,15 @@ export async function cleanupAttributes() {

Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.

## Reserved keys

When `start()` is called from inside a running workflow or step, the new run is automatically tagged with two reserved attributes:

- `$parentRunId`: the run that started it.
- `$rootRunId`: the root of the chain. It is inherited, so every run in a daisy chain or fan-out shares one root id.

Top-level runs (started outside any workflow or step) are not tagged.

## Viewing attributes

The run details panel in the observability UI shows the run's current attributes as key-value rows. Reserved `$`-prefixed keys are marked with a badge and sorted after user keys:
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ import {
import {
type Event,
getQueueTopicPrefix,
ROOT_RUN_ID_ATTRIBUTE,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand DownExpand Up@@ -242,6 +243,17 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
return true;
}

/**
* The lineage root of a loaded run: its `$rootRunId` attribute, or its own id
* when it is itself a root.
*/
function rootRunIdFrom(
attributes: Record<string, string> | undefined,
runId: string
): string {
return attributes?.[ROOT_RUN_ID_ATTRIBUTE] ?? runId;
}

/**
* Whether the run has any hook or wait that an out-of-band writer could
* append an event for between an inline step's `step_completed` write and
Expand DownExpand Up@@ -728,6 +740,7 @@ export function workflowEntrypoint(
workflowDeploymentId: bgRun.deploymentId,
workflowName,
workflowStartedAt: bgStartedAt,
rootRunId: rootRunIdFrom(bgRun.attributes, runId),
stepId: incomingStepId,
stepName: incomingStepName,
runSpecVersion: bgRun.specVersion,
Expand DownExpand Up@@ -1984,6 +1997,10 @@ export function workflowEntrypoint(
workflowRun.deploymentId,
workflowName,
workflowStartedAt,
rootRunId: rootRunIdFrom(
workflowRun.attributes,
runId
),
stepId: s.correlationId,
stepName: s.stepName,
runSpecVersion: workflowRun.specVersion,
Expand Down
156 changes: 156 additions & 0 deletions packages/core/src/runtime/start-lineage.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import {
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
} from '@workflow/world';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { contextStorage } from '../step/context-storage.js';
import { start } from './start.js';
import { setWorld } from './world.js';

vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() }));
vi.mock('../telemetry.js', () => ({
serializeTraceCarrier: vi.fn().mockResolvedValue({}),
trace: vi.fn((_name, fn) => fn(undefined)),
}));

/**
* Cross-run lineage: start() records reserved `$rootRunId` / `$parentRunId`
* attributes when it runs inside another run, so a daisy chain or fan-out
* groups under one root id. The lineage is a pure read of the ambient step
* context: the runtime fills both the parent run id and its root from the run
* it already has loaded, so start() never reads back the parent (`runs.get` is
* never called). A top-level start() records nothing.
*/
describe('start() cross-run lineage', () => {
let eventsCreate: ReturnType<typeof vi.fn>;
let runsGet: ReturnType<typeof vi.fn>;
let queue: ReturnType<typeof vi.fn>;

// The runtime requires the world to declare the current spec version; the
// per-run spec is driven separately via `opts.specVersion` where needed.
function useWorld() {
setWorld({
specVersion: SPEC_VERSION_CURRENT,
getDeploymentId: vi.fn().mockResolvedValue('deploy_123'),
events: { create: eventsCreate },
runs: { get: runsGet },
queue,
} as any);
}

beforeEach(() => {
eventsCreate = vi.fn().mockImplementation((runId) =>
Promise.resolve({
run: { runId: runId ?? 'wrun_x', status: 'pending' },
})
);
runsGet = vi.fn();
queue = vi.fn().mockResolvedValue(undefined);
useWorld();
});

afterEach(() => {
setWorld(undefined);
vi.clearAllMocks();
});

const wf = (id: string) =>
Object.assign(() => Promise.resolve('ok'), { workflowId: id });

/** Attributes seeded onto the run_created event for the first start() call. */
function seededAttributes(): Record<string, string> | undefined {
return eventsCreate.mock.calls[0]?.[1]?.eventData?.attributes;
}

/**
* Run `fn` as if executing inside a parent run's step context. Pass
* `rootRunId` to model the runtime having put the parent's root on the
* context (the wired path); omit it to exercise the anchor-to-parent default.
*/
function insideRun<T>(
parentRunId: string,
fn: () => Promise<T>,
rootRunId?: string
): Promise<T> {
return contextStorage.run(
{
stepMetadata: {
stepName: 'start',
stepId: 'step_1',
stepStartedAt: new Date(),
attempt: 1,
},
workflowMetadata: {
workflowName: 'parent',
workflowRunId: parentRunId,
workflowStartedAt: new Date(),
url: 'http://localhost:3000',
features: { encryption: false },
},
rootRunId,
ops: [],
} as any,
fn
);
}

it('records no lineage for a top-level start()', async () => {
await start(wf('test-workflow'), []);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});

it('inherits the root from the context with no read-back', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), []),
'wrun_root'
);

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
});
});

it('anchors the root to the parent when the context carries none', async () => {
await insideRun('wrun_parent', () => start(wf('child-workflow'), []));

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_parent',
$parentRunId: 'wrun_parent',
});
});

it('merges caller-provided attributes over the inferred lineage', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), [], { attributes: { tenant: 't1' } }),
'wrun_root'
);

const expected = {
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
tenant: 't1',
};
expect(seededAttributes()).toEqual(expected);
// Lineage must also ride the resilient-start queue input, not just the
// run_created event, so both creation paths carry it.
expect(queue.mock.calls[0]?.[1]?.runInput?.attributes).toEqual(expected);
});

it('records no lineage when the run predates attribute support', async () => {
await insideRun('wrun_parent', () =>
start(wf('child-workflow'), [], {
specVersion: SPEC_VERSION_SUPPORTS_ATTRIBUTES - 1,
})
);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});
});
48 changes: 42 additions & 6 deletions packages/core/src/runtime/start.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import { workflowDisplayName } from '@workflow/utils/parse-name';
import type { WorkflowInvokePayload, World } from '@workflow/world';
import {
isLegacySpecVersion,
PARENT_RUN_ID_ATTRIBUTE,
ROOT_RUN_ID_ATTRIBUTE,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand All@@ -19,6 +21,7 @@ import {
dehydrateWorkflowArguments,
SerializationFormat,
} from '../serialization.js';
import { contextStorage } from '../step/context-storage.js';
import * as Attribute from '../telemetry/semantic-conventions.js';
import { serializeTraceCarrier, trace } from '../telemetry.js';
import { version as workflowCoreVersion } from '../version.js';
Expand All@@ -42,6 +45,28 @@ const CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS = 2_000;
/** ULID generator for client-side runId generation */
const ulid = monotonicFactory();

/**
* Cross-run lineage for a run being started from inside another run.
*
* The ambient step context carries the parent run id and the root of its
* lineage; the runtime fills both from the run it already has loaded, so this
* is a pure context read with no I/O. The new run records `$parentRunId` (the
* edge) and inherits the parent's `$rootRunId` (the parent itself when it is a
* root), so a daisy chain or fan-out of any depth groups under one root id.
* Returns `undefined` for a top-level `start()`, which has no context, so
* standalone runs carry no lineage.
*/
function resolveLineageAttributes(): Record<string, string> | undefined {
const store = contextStorage.getStore();
const parentRunId = store?.workflowMetadata?.workflowRunId;
if (!parentRunId) return undefined;

return {
[ROOT_RUN_ID_ATTRIBUTE]: store.rootRunId ?? parentRunId,
[PARENT_RUN_ID_ATTRIBUTE]: parentRunId,
};
}

// `deploymentId: 'latest'` is a no-op in Worlds without atomic deployments.
// The warning that explains this only needs to fire once per process: a
// workflow that hardcodes 'latest' for its Vercel deployment would otherwise
Expand DownExpand Up@@ -360,13 +385,24 @@ export async function start<TArgs extends unknown[], TResult>(
changes.map(({ key, value }) => [key, value as string])
);
}
// Seed payload shared by run_created and the resilient-start queue
// input. The flag rides along so server-side validation matches the
// client-side check above on both paths.
const attributeSeed = attributes

// Cross-run lineage: the reserved keys ride on the run's existing
// attributes, so they add no extra write. Caller attributes are spread
// last, so a caller with allowReservedAttributes can deliberately
// re-parent.
const lineage =
specVersion >= SPEC_VERSION_SUPPORTS_ATTRIBUTES
? resolveLineageAttributes()
: undefined;
const runAttributes = lineage
? { ...lineage, ...attributes }
: attributes;

// Shared by the run_created event and the resilient-start queue input.
const attributeSeed = runAttributes
? {
attributes,
...(allowReservedAttributes
attributes: runAttributes,
...(allowReservedAttributes || lineage != null
? { allowReservedAttributes: true as const }
: {}),
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runtime/step-executor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,8 @@ export interface StepExecutorParams {
workflowDeploymentId?: string;
workflowName: string;
workflowStartedAt: number;
/** Root run id of this run's lineage, carried into the step context. */
rootRunId?: string;
stepId: string;
stepName: string;
encryptionKey?: CryptoKey;
Expand DownExpand Up@@ -690,6 +692,7 @@ export async function executeStep(
features: { encryption: !!encryptionKey },
},
workflowDeploymentId: params.workflowDeploymentId,
rootRunId: params.rootRunId,
ops,
preCompletionOps,
closureVars: hydratedInput.closureVars,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/step/context-storage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ export type StepContext = {
workflowMetadata: WorkflowMetadata;
/** Deployment that owns the current workflow run, used for forwarded streams. */
workflowDeploymentId?: string;
/**
* Root run id of the current run's lineage (`$rootRunId`), so a nested
* `start()` inherits it without reloading the parent. Set by the runtime from
* the run it already has loaded.
*/
rootRunId?: string;
ops: Promise<void>[];
/**
* Operations that MUST be durably committed before the step's
Expand Down
2 changes: 2 additions & 0 deletions packages/world-testing/src/index.mts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import { errors } from './errors.mjs';
import { hooks } from './hooks.mjs';
import { idempotency } from './idempotency.mjs';
import { inlineExecution } from './inline-execution.mjs';
import { lineage } from './lineage.mjs';
import { nullByte } from './null-byte.mjs';

export function createTestSuite(pkgName: string) {
Expand All@@ -12,4 +13,5 @@ export function createTestSuite(pkgName: string) {
nullByte(pkgName);
errors(pkgName);
inlineExecution(pkgName);
lineage(pkgName);
}
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); } })(); })(); feat: cross-run lineage via reserved run attributes by rchasman · Pull Request #2153 · 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
6 changes: 6 additions & 0 deletions .changeset/start-cross-run-lineage.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"@workflow/world": patch
---

Record cross-run lineage when `start()` is called from inside a workflow or step: the new run is tagged with `$parentRunId` (its direct parent) and inherits the parent's `$rootRunId`, so a daisy chain or fan-out of any depth groups under one root id.
9 changes: 9 additions & 0 deletions docs/content/docs/v5/observability/attributes.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,15 @@ export async function cleanupAttributes() {

Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.

## Reserved keys

When `start()` is called from inside a running workflow or step, the new run is automatically tagged with two reserved attributes:

- `$parentRunId`: the run that started it.
- `$rootRunId`: the root of the chain. It is inherited, so every run in a daisy chain or fan-out shares one root id.

Top-level runs (started outside any workflow or step) are not tagged.

## Viewing attributes

The run details panel in the observability UI shows the run's current attributes as key-value rows. Reserved `$`-prefixed keys are marked with a badge and sorted after user keys:
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ import {
import {
type Event,
getQueueTopicPrefix,
ROOT_RUN_ID_ATTRIBUTE,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand DownExpand Up@@ -242,6 +243,17 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
return true;
}

/**
* The lineage root of a loaded run: its `$rootRunId` attribute, or its own id
* when it is itself a root.
*/
function rootRunIdFrom(
attributes: Record<string, string> | undefined,
runId: string
): string {
return attributes?.[ROOT_RUN_ID_ATTRIBUTE] ?? runId;
}

/**
* Whether the run has any hook or wait that an out-of-band writer could
* append an event for between an inline step's `step_completed` write and
Expand DownExpand Up@@ -728,6 +740,7 @@ export function workflowEntrypoint(
workflowDeploymentId: bgRun.deploymentId,
workflowName,
workflowStartedAt: bgStartedAt,
rootRunId: rootRunIdFrom(bgRun.attributes, runId),
stepId: incomingStepId,
stepName: incomingStepName,
runSpecVersion: bgRun.specVersion,
Expand DownExpand Up@@ -1984,6 +1997,10 @@ export function workflowEntrypoint(
workflowRun.deploymentId,
workflowName,
workflowStartedAt,
rootRunId: rootRunIdFrom(
workflowRun.attributes,
runId
),
stepId: s.correlationId,
stepName: s.stepName,
runSpecVersion: workflowRun.specVersion,
Expand Down
156 changes: 156 additions & 0 deletions packages/core/src/runtime/start-lineage.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import {
SPEC_VERSION_CURRENT,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
} from '@workflow/world';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { contextStorage } from '../step/context-storage.js';
import { start } from './start.js';
import { setWorld } from './world.js';

vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() }));
vi.mock('../telemetry.js', () => ({
serializeTraceCarrier: vi.fn().mockResolvedValue({}),
trace: vi.fn((_name, fn) => fn(undefined)),
}));

/**
* Cross-run lineage: start() records reserved `$rootRunId` / `$parentRunId`
* attributes when it runs inside another run, so a daisy chain or fan-out
* groups under one root id. The lineage is a pure read of the ambient step
* context: the runtime fills both the parent run id and its root from the run
* it already has loaded, so start() never reads back the parent (`runs.get` is
* never called). A top-level start() records nothing.
*/
describe('start() cross-run lineage', () => {
let eventsCreate: ReturnType<typeof vi.fn>;
let runsGet: ReturnType<typeof vi.fn>;
let queue: ReturnType<typeof vi.fn>;

// The runtime requires the world to declare the current spec version; the
// per-run spec is driven separately via `opts.specVersion` where needed.
function useWorld() {
setWorld({
specVersion: SPEC_VERSION_CURRENT,
getDeploymentId: vi.fn().mockResolvedValue('deploy_123'),
events: { create: eventsCreate },
runs: { get: runsGet },
queue,
} as any);
}

beforeEach(() => {
eventsCreate = vi.fn().mockImplementation((runId) =>
Promise.resolve({
run: { runId: runId ?? 'wrun_x', status: 'pending' },
})
);
runsGet = vi.fn();
queue = vi.fn().mockResolvedValue(undefined);
useWorld();
});

afterEach(() => {
setWorld(undefined);
vi.clearAllMocks();
});

const wf = (id: string) =>
Object.assign(() => Promise.resolve('ok'), { workflowId: id });

/** Attributes seeded onto the run_created event for the first start() call. */
function seededAttributes(): Record<string, string> | undefined {
return eventsCreate.mock.calls[0]?.[1]?.eventData?.attributes;
}

/**
* Run `fn` as if executing inside a parent run's step context. Pass
* `rootRunId` to model the runtime having put the parent's root on the
* context (the wired path); omit it to exercise the anchor-to-parent default.
*/
function insideRun<T>(
parentRunId: string,
fn: () => Promise<T>,
rootRunId?: string
): Promise<T> {
return contextStorage.run(
{
stepMetadata: {
stepName: 'start',
stepId: 'step_1',
stepStartedAt: new Date(),
attempt: 1,
},
workflowMetadata: {
workflowName: 'parent',
workflowRunId: parentRunId,
workflowStartedAt: new Date(),
url: 'http://localhost:3000',
features: { encryption: false },
},
rootRunId,
ops: [],
} as any,
fn
);
}

it('records no lineage for a top-level start()', async () => {
await start(wf('test-workflow'), []);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});

it('inherits the root from the context with no read-back', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), []),
'wrun_root'
);

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
});
});

it('anchors the root to the parent when the context carries none', async () => {
await insideRun('wrun_parent', () => start(wf('child-workflow'), []));

expect(runsGet).not.toHaveBeenCalled();
expect(seededAttributes()).toEqual({
$rootRunId: 'wrun_parent',
$parentRunId: 'wrun_parent',
});
});

it('merges caller-provided attributes over the inferred lineage', async () => {
await insideRun(
'wrun_parent',
() => start(wf('child-workflow'), [], { attributes: { tenant: 't1' } }),
'wrun_root'
);

const expected = {
$rootRunId: 'wrun_root',
$parentRunId: 'wrun_parent',
tenant: 't1',
};
expect(seededAttributes()).toEqual(expected);
// Lineage must also ride the resilient-start queue input, not just the
// run_created event, so both creation paths carry it.
expect(queue.mock.calls[0]?.[1]?.runInput?.attributes).toEqual(expected);
});

it('records no lineage when the run predates attribute support', async () => {
await insideRun('wrun_parent', () =>
start(wf('child-workflow'), [], {
specVersion: SPEC_VERSION_SUPPORTS_ATTRIBUTES - 1,
})
);

expect(seededAttributes()).toBeUndefined();
expect(runsGet).not.toHaveBeenCalled();
});
});
48 changes: 42 additions & 6 deletions packages/core/src/runtime/start.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import { workflowDisplayName } from '@workflow/utils/parse-name';
import type { WorkflowInvokePayload, World } from '@workflow/world';
import {
isLegacySpecVersion,
PARENT_RUN_ID_ATTRIBUTE,
ROOT_RUN_ID_ATTRIBUTE,
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT,
SPEC_VERSION_SUPPORTS_COMPRESSION,
Expand All@@ -19,6 +21,7 @@ import {
dehydrateWorkflowArguments,
SerializationFormat,
} from '../serialization.js';
import { contextStorage } from '../step/context-storage.js';
import * as Attribute from '../telemetry/semantic-conventions.js';
import { serializeTraceCarrier, trace } from '../telemetry.js';
import { version as workflowCoreVersion } from '../version.js';
Expand All@@ -42,6 +45,28 @@ const CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS = 2_000;
/** ULID generator for client-side runId generation */
const ulid = monotonicFactory();

/**
* Cross-run lineage for a run being started from inside another run.
*
* The ambient step context carries the parent run id and the root of its
* lineage; the runtime fills both from the run it already has loaded, so this
* is a pure context read with no I/O. The new run records `$parentRunId` (the
* edge) and inherits the parent's `$rootRunId` (the parent itself when it is a
* root), so a daisy chain or fan-out of any depth groups under one root id.
* Returns `undefined` for a top-level `start()`, which has no context, so
* standalone runs carry no lineage.
*/
function resolveLineageAttributes(): Record<string, string> | undefined {
const store = contextStorage.getStore();
const parentRunId = store?.workflowMetadata?.workflowRunId;
if (!parentRunId) return undefined;

return {
[ROOT_RUN_ID_ATTRIBUTE]: store.rootRunId ?? parentRunId,
[PARENT_RUN_ID_ATTRIBUTE]: parentRunId,
};
}

// `deploymentId: 'latest'` is a no-op in Worlds without atomic deployments.
// The warning that explains this only needs to fire once per process: a
// workflow that hardcodes 'latest' for its Vercel deployment would otherwise
Expand DownExpand Up@@ -360,13 +385,24 @@ export async function start<TArgs extends unknown[], TResult>(
changes.map(({ key, value }) => [key, value as string])
);
}
// Seed payload shared by run_created and the resilient-start queue
// input. The flag rides along so server-side validation matches the
// client-side check above on both paths.
const attributeSeed = attributes

// Cross-run lineage: the reserved keys ride on the run's existing
// attributes, so they add no extra write. Caller attributes are spread
// last, so a caller with allowReservedAttributes can deliberately
// re-parent.
const lineage =
specVersion >= SPEC_VERSION_SUPPORTS_ATTRIBUTES
? resolveLineageAttributes()
: undefined;
const runAttributes = lineage
? { ...lineage, ...attributes }
: attributes;

// Shared by the run_created event and the resilient-start queue input.
const attributeSeed = runAttributes
? {
attributes,
...(allowReservedAttributes
attributes: runAttributes,
...(allowReservedAttributes || lineage != null
? { allowReservedAttributes: true as const }
: {}),
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runtime/step-executor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,8 @@ export interface StepExecutorParams {
workflowDeploymentId?: string;
workflowName: string;
workflowStartedAt: number;
/** Root run id of this run's lineage, carried into the step context. */
rootRunId?: string;
stepId: string;
stepName: string;
encryptionKey?: CryptoKey;
Expand DownExpand Up@@ -690,6 +692,7 @@ export async function executeStep(
features: { encryption: !!encryptionKey },
},
workflowDeploymentId: params.workflowDeploymentId,
rootRunId: params.rootRunId,
ops,
preCompletionOps,
closureVars: hydratedInput.closureVars,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/step/context-storage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,12 @@ export type StepContext = {
workflowMetadata: WorkflowMetadata;
/** Deployment that owns the current workflow run, used for forwarded streams. */
workflowDeploymentId?: string;
/**
* Root run id of the current run's lineage (`$rootRunId`), so a nested
* `start()` inherits it without reloading the parent. Set by the runtime from
* the run it already has loaded.
*/
rootRunId?: string;
ops: Promise<void>[];
/**
* Operations that MUST be durably committed before the step's
Expand Down
2 changes: 2 additions & 0 deletions packages/world-testing/src/index.mts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import { errors } from './errors.mjs';
import { hooks } from './hooks.mjs';
import { idempotency } from './idempotency.mjs';
import { inlineExecution } from './inline-execution.mjs';
import { lineage } from './lineage.mjs';
import { nullByte } from './null-byte.mjs';

export function createTestSuite(pkgName: string) {
Expand All@@ -12,4 +13,5 @@ export function createTestSuite(pkgName: string) {
nullByte(pkgName);
errors(pkgName);
inlineExecution(pkgName);
lineage(pkgName);
}
Loading