Skip to content
6 changes: 6 additions & 0 deletions .changeset/hook-received-terminal-run-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/world-local': patch
'@workflow/world-postgres': patch
---

Reject `hook_received` on terminal runs, including when the termination commits concurrently (cross-process) and for legacy (pre-event-sourcing) runs.
38 changes: 37 additions & 1 deletion packages/world-local/src/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const isWindows = process.platform === 'win32';
* are briefly locked by another process or antivirus. This wrapper adds
* exponential backoff retry logic. On non-Windows platforms, executes directly.
*/
async function withWindowsRetry<T>(
export async function withWindowsRetry<T>(
fn: () => Promise<T>,
maxRetries = 5
): Promise<T> {
Expand Down Expand Up @@ -477,6 +477,42 @@ export async function writeExclusive(
}
}

/**
* Atomically promote a previously staged file (see {@link writeExclusive})
* to its visible destination via a hard link. The single `link(2)` call is
* the linearization point:
*
* - `'linked'` — this call made the destination visible.
* - `'exists'` — another writer published the destination first
* (same meaning as `writeExclusive` returning false).
* - `'missing'` — the staged file was concurrently unlinked, so the
* promotion atomically lost to whoever removed it and
* the destination was never made visible.
*
* The staged file is left in place on success; callers unlink it
* themselves (a leftover staged file is harmless — it is not at a
* reader-visible path).
*/
export async function promoteExclusive(
stagedPath: string,
filePath: string
): Promise<'linked' | 'exists' | 'missing'> {
try {
await withEnsuredDirectory(path.dirname(filePath), () =>
withWindowsRetry(() => fs.link(stagedPath, filePath))
);
return 'linked';
} catch (error: any) {
if (error.code === 'EEXIST') {
return 'exists';
}
if (error.code === 'ENOENT') {
return 'missing';
}
throw error;
}
}

export async function listJSONFiles(dirPath: string): Promise<string[]> {
return listFilesByExtension(dirPath, '.json');
}
Expand Down
Loading
Loading