Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
bd33c10
feat(core): add hook token retention contract
NathanColosimo Jul 9, 2026
f7a5865
refactor(core): constrain hook retention options
NathanColosimo Jul 10, 2026
4fe2918
fix(core): preserve boolean hook visibility options
NathanColosimo Jul 10, 2026
0cd7401
revert(core): preserve HookOptions interface
NathanColosimo Jul 10, 2026
8d54bac
docs(core): clarify retained conflict ownership
NathanColosimo Jul 10, 2026
eda3a40
docs(core): retain newest-wins conflict pattern
NathanColosimo Jul 10, 2026
58a5a4b
docs(core): simplify hook retention guidance
NathanColosimo Jul 10, 2026
efa6404
docs(core): explain retained token cleanup
NathanColosimo Jul 10, 2026
1771b8d
docs(core): simplify idempotency guidance
NathanColosimo Jul 10, 2026
cdc543e
docs(core): clarify retained token results
NathanColosimo Jul 10, 2026
08fe520
refactor(core): rename hook token expiration option
NathanColosimo Jul 10, 2026
f8e6c6c
chore(core): name hook expiration changeset
NathanColosimo Jul 10, 2026
7549626
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 10, 2026
2b67594
docs(core): simplify Hook expiration language
NathanColosimo Jul 10, 2026
03cb847
docs(core): clarify Hook expiration deadline
NathanColosimo Jul 10, 2026
ad5442f
docs(core): remove Hook deadline caveat
NathanColosimo Jul 10, 2026
9d60f99
refactor(core): align Hook expiration field names
NathanColosimo Jul 10, 2026
e40c3f5
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 10, 2026
0deba85
docs(core): narrow Hook expiration documentation
NathanColosimo Jul 10, 2026
8b75665
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 10, 2026
1ea2bd3
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 13, 2026
ecd2c61
docs(core): clarify hook expiration availability
NathanColosimo Jul 13, 2026
4311f0a
Merge branch 'main' into codex/hook-retention-core
VaguelySerious Jul 14, 2026
7eee23b
Update packages/core/src/workflow/hook.ts
NathanColosimo Jul 14, 2026
e65f1d2
docs(core): clarify Hook token expiration behavior
NathanColosimo Jul 14, 2026
8d9923b
docs(core): explain active Hook expiration behavior
NathanColosimo Jul 14, 2026
d6ebc67
feat(world): advertise hook ttl capability
NathanColosimo Jul 15, 2026
be2b20f
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 16, 2026
e560b35
fix(core): validate hook ttl capability after main merge
NathanColosimo Jul 16, 2026
d45f8b0
refactor(core): rename hook expiry to minimum retention
NathanColosimo Jul 16, 2026
cef4190
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 16, 2026
e26ca7c
docs: keep hook retention guidance on v5
NathanColosimo Jul 16, 2026
6731caa
docs: define retained run availability
NathanColosimo Jul 16, 2026
02eceb9
fix(core): validate Hook retention at creation
NathanColosimo Jul 16, 2026
8090ef9
feat(core): define retained Hook lookup semantics
NathanColosimo Jul 16, 2026
b98df86
refactor(core): simplify hook retention checks
NathanColosimo Jul 16, 2026
0db7b3f
feat(world-local): support Hook token expiration
NathanColosimo Jul 13, 2026
2d20344
fix(world-local): make hook recovery atomic
NathanColosimo Jul 14, 2026
22c7f40
refactor(world-local): align Hook minimum retention
NathanColosimo Jul 16, 2026
d9fa89c
fix(world-local): preserve Hook creation order
NathanColosimo Jul 16, 2026
b257f46
fix(world-local): expose retained Hooks consistently
NathanColosimo Jul 16, 2026
a30a3d7
refactor(world-local): simplify retained hook storage
NathanColosimo Jul 16, 2026
f37fa91
fix(world-local): allow stale lock recovery
NathanColosimo Jul 16, 2026
7e1fb94
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 27, 2026
628d154
refactor(world-local): simplify hook retention storage
NathanColosimo Jul 27, 2026
0057b6c
fix(world-local): serialize expired hook token handoff
NathanColosimo Jul 27, 2026
d127ab4
fix(world-local): preserve hook creation order
NathanColosimo Jul 27, 2026
737a7b1
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 27, 2026
5fad298
Merge remote-tracking branch 'origin/main' into HEAD
NathanColosimo Aug 1, 2026
343ce77
feat(world-postgres): retain hook tokens after runs end
NathanColosimo Aug 1, 2026
4a3036b
refactor(world-local): clarify hook availability cleanup
NathanColosimo Aug 2, 2026
28d27f5
docs: note Local World Hook retention support
NathanColosimo Aug 2, 2026
281ac39
refactor(world-postgres): reuse terminal run statuses
NathanColosimo Aug 2, 2026
8c442c4
Merge remote-tracking branch 'origin/codex/hook-retention-local' into…
NathanColosimo Aug 2, 2026
3bbdfb5
docs: note Postgres Hook retention support
NathanColosimo Aug 2, 2026
13f633b
feat(world-vercel): support Hook minimum retention
NathanColosimo Aug 2, 2026
6f1e4c2
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Aug 3, 2026
6206fb3
Merge remote-tracking branch 'origin/codex/hook-retention-local' into…
NathanColosimo Aug 3, 2026
ad58321
fix(world-postgres): expose hook retention deadline
NathanColosimo Aug 3, 2026
c1389d2
fix(world-local): harden hook retention persistence
NathanColosimo Aug 3, 2026
dea4195
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Aug 3, 2026
731390a
fix(web-shared): render hook retention deadline
NathanColosimo Aug 3, 2026
d3bf2a3
fix(world-postgres): exclude unsupported hook retention
NathanColosimo Aug 3, 2026
46d3b2f
Fix: Exhaustive `Record<AttributeKey, ...>` in `attribute-panel.tsx` …
vercel[bot] Aug 3, 2026
99db94f
docs(world-postgres): clarify expired hook rows
NathanColosimo Aug 3, 2026
a50817c
feat(world-local): enforce Hook retention limit
NathanColosimo Aug 3, 2026
956d5bf
Merge branch 'codex/hook-retention-local' into codex/hook-retention-p…
NathanColosimo Aug 3, 2026
8a70638
docs(world-local): clarify retention limit error
NathanColosimo Aug 3, 2026
38d92e4
feat(world-postgres): enforce Hook retention limit
NathanColosimo Aug 3, 2026
e6bb085
Merge branch 'codex/hook-retention-local' into codex/hook-retention-p…
NathanColosimo Aug 3, 2026
4783206
Merge branch 'codex/hook-retention-postgres' into codex/hook-retentio…
NathanColosimo Aug 3, 2026
6d3e6dc
docs(world-vercel): document Hook retention limit
NathanColosimo Aug 3, 2026
d34b0d4
docs(world): clarify Hook retention deadline
NathanColosimo Aug 3, 2026
e174f50
Merge branch 'codex/hook-retention-local' into codex/hook-retention-p…
NathanColosimo Aug 3, 2026
e6d9b61
fix(world): remove duplicate Hook retention field
NathanColosimo Aug 3, 2026
c6ea522
Merge branch 'codex/hook-retention-postgres' into codex/hook-retentio…
NathanColosimo Aug 3, 2026
b0cd01f
fix(web-shared): remove duplicate retention renderer
NathanColosimo Aug 3, 2026
6a58eb0
Merge branch 'codex/hook-retention-postgres' into codex/hook-retentio…
NathanColosimo Aug 3, 2026
f204f71
docs(hooks): link retention configuration
NathanColosimo Aug 3, 2026
4b915d7
Merge branch 'codex/hook-retention-local' into codex/hook-retention-p…
NathanColosimo Aug 3, 2026
03ae58a
test(world): remove redundant retention coercion case
NathanColosimo Aug 3, 2026
6ec2f5d
Merge branch 'codex/hook-retention-postgres' into codex/hook-retentio…
NathanColosimo Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/local-hook-min-retention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@workflow/world': minor
'@workflow/world-local': minor
'@workflow/web-shared': patch
'@workflow/world-postgres': patch
---

Keep Hook tokens reserved through their configured minimum retention.
5 changes: 5 additions & 0 deletions .changeset/postgres-hook-min-retention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/world-postgres': minor
---

Keep Hook tokens reserved through their configured minimum retention.
5 changes: 5 additions & 0 deletions .changeset/vercel-hook-min-retention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/world-vercel': minor
---

Support Hook minimum retention on Vercel.
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ After the workflow ends, [`getHookByToken()`](/docs/api-reference/workflow-api/g
</Callout>

<Callout type="warn">
This option is experimental. If the configured World does not support it, the workflow fails when registering the Hook. `createWebhook()` does not accept this option.
This option is experimental. Worlds can limit how long tokens are retained; see [World configuration](/docs/configuration/worlds) for each World's limit. If the configured World does not support minimum retention, the workflow fails when registering the Hook. `createWebhook()` does not accept this option.
</Callout>

### Waiting for Multiple Payloads
Expand Down
14 changes: 14 additions & 0 deletions docs/content/docs/v5/configuration/worlds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ The Local World is the default outside Vercel and is intended for development.
- Default: `true`
- Re-enqueues pending and running local runs when the World starts. Set the environment variable to `0` or `false` to skip recovery; the factory option wins when both are set.

### `WORKFLOW_LOCAL_HOOK_RETENTION_LIMIT_DAYS`

- Factory option: none
- Default: `30`
- Maximum [`experimental_minRetention`](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) accepted by the Local World, in days.
- Set this to the same limit as your production World so oversized values fail during local development.

### `tag`

- Environment variable: none
Expand Down Expand Up @@ -160,6 +167,13 @@ The Postgres World is a self-hosted durable backend for long-running server proc
- Default: `pg` default
- Maximum size of the internal `pg.Pool` when the World creates the pool.

### `WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS`

- Factory option: none
- Default: `30`
- Maximum [`experimental_minRetention`](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) accepted by the Postgres World, in days.
- Set this to the same limit as your production World so oversized values fail during development.

### `namespace`

- Environment variable fallback: `WORKFLOW_QUEUE_NAMESPACE`
Expand Down
4 changes: 4 additions & 0 deletions docs/content/worlds/v5/local.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ Maximum gap in milliseconds between response body chunks from a local queue hand

Whether pending and running runs found in the data directory are re-enqueued when the world starts. Set to `0` or `false` to skip recovery and leave stale runs untouched. Default: `true`

### `WORKFLOW_LOCAL_HOOK_RETENTION_LIMIT_DAYS`

Maximum [`experimental_minRetention`](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) accepted by the Local World, in days. Default: `30`. Set this to the same limit as your production World so oversized values fail during local development.

### `WORKFLOW_STREAM_FLUSH_INTERVAL_MS`

Group-commit window, in milliseconds, for the leading chunk of an idle stream. Default: `0` (dispatch immediately). A positive value holds the first chunk up to that long to collect a group — trading first-chunk latency for fewer requests. Chunks arriving while a request is in flight always coalesce into the next group regardless.
Expand Down
4 changes: 4 additions & 0 deletions docs/content/worlds/v5/postgres.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ For higher worker concurrency, Graphile Worker recommends setting `maxPoolSize`

Set to `1` when the application or framework coordinates shutdown and awaits `world.close()` before closing its workflow HTTP server and any caller-owned pool. Default: unset (`false`).

### `WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS`

Maximum [`experimental_minRetention`](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) accepted by the Postgres World, in days. Default: `30`. Set this to the same limit as your production World so oversized values fail during development.

### `WORKFLOW_QUEUE_NAMESPACE`

Queue topic namespace shared by build output and the Postgres World. Default: unset.
Expand Down
1 change: 1 addition & 0 deletions docs/content/worlds/v5/vercel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const run = await start(myWorkflow, [input], { region: "sfo1" });
## Limitations

- **No run migration** - A run's region is fixed at creation. Existing runs cannot be moved to a different region.
- **Hook minimum retention** - [`experimental_minRetention`](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) cannot exceed 30 days.

## Observability

Expand Down
30 changes: 30 additions & 0 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
Expand Down Expand Up @@ -2047,6 +2047,36 @@
}
);

test(
'hookMinRetentionWorkflow - terminal Hook cannot resume and its token stays unavailable',
{ timeout: 60_000 },
async () => {
const token = `retained-${Math.random().toString(36).slice(2)}`;
const owner = await start(await e2e('hookMinRetentionWorkflow'), [
token,
60_000,
]);

expect(await owner.returnValue).toEqual({ role: 'owner' });

const hook = await getHookByToken(token);
expect(hook.runId).toBe(owner.runId);
await expect(resumeHook(hook, { duplicate: true })).rejects.toSatisfy(
(error: unknown) => HookNotFoundError.is(error)
);

const duplicate = await start(await e2e('hookMinRetentionWorkflow'), [
token,
60_000,
]);
expect(await duplicate.returnValue).toEqual({
role: 'duplicate',
conflictRunId: owner.runId,
conflictStatus: 'completed',
});
}
);

test(
'hookAdoptOwnerResultWorkflow - duplicate adopts the owner result via conflict.returnValue',
{ timeout: 120_000 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ const attributeOrder: AttributeKey[] = [
'runId',
'attempt',
'token',
'tokenRetentionUntil',
'isWebhook',
'isSystem',
'receivedCount',
Expand Down Expand Up @@ -322,6 +323,7 @@ const attributeDisplayNames: Partial<Record<AttributeKey, string>> = {
eventId: 'Event ID',
runId: 'Run ID',
token: 'Token',
tokenRetentionUntil: 'Minimum Retention Until',
eventType: 'Event Type',
errorCode: 'Error Code',
correlationId: 'Correlation ID',
Expand Down Expand Up @@ -441,6 +443,7 @@ const attributeToDisplayFn: Record<
attempt: (value: unknown) => String(value),
// Hook details
token: (value: unknown) => String(value),
tokenRetentionUntil: timestampWithTooltipOrNull,
isWebhook: (value: unknown) => String(value),
isSystem: (value: unknown) => String(value),
receivedCount: (value: unknown) => String(value),
Expand Down
2 changes: 2 additions & 0 deletions packages/world-local/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@workflow/utils": "workspace:*",
"@workflow/world": "workspace:*",
"async-sema": "3.1.1",
"proper-lockfile": "4.1.2",
"ulid": "catalog:",
"undici": "catalog:",
"zod": "catalog:"
Expand All @@ -43,6 +44,7 @@
"@opentelemetry/api": "1.9.0",
"@types/ms": "0.7.34",
"@types/node": "catalog:",
"@types/proper-lockfile": "4.1.4",
"@workflow/tsconfig": "workspace:*",
"ms": "2.1.3",
"typescript": "catalog:",
Expand Down
14 changes: 8 additions & 6 deletions packages/world-local/src/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,8 @@ interface PaginatedFileSystemQueryConfig<T> {
cachedItems?: ReadonlyMap<string, T>;
filePrefix?: string;
fileIdFilter?: (fileId: string) => boolean;
filter?: (item: T) => boolean;
/** Runs concurrently for each read batch and must not mutate storage. */
filter?: (item: T) => boolean | Promise<boolean>;
sortOrder?: 'asc' | 'desc';
limit?: number;
cursor?: string;
Expand Down Expand Up @@ -689,11 +690,13 @@ export async function paginatedFileSystemQuery<T extends { createdAt: Date }>(
const loadedBatch = await Promise.all(
batch.map(async (fileId): Promise<T | null> => {
const filePath = path.join(resolvedDirectory, `${fileId}.json`);
let item: T | null;
try {
const cachedItem = cachedItems?.get(filePath);
return cachedItem === undefined
? await readJSON(filePath, schema)
: structuredClone(cachedItem);
item =
cachedItem === undefined
? await readJSON(filePath, schema)
: structuredClone(cachedItem);
} catch (error: unknown) {
// We don't expect zod errors to happen, but if the JSON does get malformed,
// we skip the item. Preferably, we'd have a way to mark items as malformed,
Expand All @@ -707,13 +710,12 @@ export async function paginatedFileSystemQuery<T extends { createdAt: Date }>(
}
throw error;
}
return item && filter && !(await filter(item)) ? null : item;
})
);

for (const item of loadedBatch) {
if (!item) continue;
// Apply custom filter early if provided
if (filter && !filter(item)) continue;

// Double-check cursor filtering with actual createdAt from JSON
// (in case ULID timestamp differs from stored createdAt)
Expand Down
1 change: 1 addition & 0 deletions packages/world-local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export function createWorld(args?: Partial<Config>): LocalWorld {
return {
specVersion: SPEC_VERSION_CURRENT,
capabilities: {
hookRetention: { active: true },
// world-local deduplicates concurrent `hook_received` writes sharing a
// `(runId, resumeId)` via a filesystem sidecar claim (see
// events-storage.ts `claimHookResume`), so resumeHook()'s parallel fast
Expand Down
Loading
Loading