diff --git a/.changeset/run-get-writable.md b/.changeset/run-get-writable.md
new file mode 100644
index 0000000000..bb7e768ba9
--- /dev/null
+++ b/.changeset/run-get-writable.md
@@ -0,0 +1,6 @@
+---
+'@workflow/core': minor
+'workflow': minor
+---
+
+Add `Run#getWritable()` to append to an existing run's stream.
diff --git a/docs/content/docs/v4/api-reference/workflow-api/get-run.mdx b/docs/content/docs/v4/api-reference/workflow-api/get-run.mdx
index 27764ab352..484c4fdd93 100644
--- a/docs/content/docs/v4/api-reference/workflow-api/get-run.mdx
+++ b/docs/content/docs/v4/api-reference/workflow-api/get-run.mdx
@@ -67,6 +67,16 @@ import type { WorkflowReadableStreamOptions } from "workflow/api";
export default WorkflowReadableStreamOptions;`}
/>
+#### WorkflowRunWritableStreamOptions
+
+
+
+See [Writing to another run's stream](/docs/foundations/streaming#writing-to-another-runs-stream) for usage and lifecycle details.
+
#### StopSleepOptions
(); // [!code highlight]
+ const writer = writable.getWriter();
+
+ await writer.write({ turn, text: "done" });
+ writer.releaseLock(); // [!code highlight]
+}
+```
+
+Pass `{ namespace: "name" }` to target a [namespaced stream](#namespaced-streams). The writable can also be forwarded through `start()` and into steps.
+
+
+Contributors should call `releaseLock()`, which flushes pending writes. Calling `close()` closes the shared stream for every writer.
+
+
+The API grants append access, not read access or additional authorization. The owning run controls the stream's lifecycle, and an unknown run rejects with `WorkflowRunNotFoundError`.
+
## Common patterns
### Progress updates for long-running tasks
diff --git a/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx b/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx
index 88bba5eb20..808b1290c6 100644
--- a/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx
@@ -65,6 +65,16 @@ import type { WorkflowReadableStreamOptions } from "workflow/api";
export default WorkflowReadableStreamOptions;`}
/>
+#### WorkflowRunWritableStreamOptions
+
+
+
+See [Writing to another run's stream](/docs/foundations/streaming#writing-to-another-runs-stream) for usage and lifecycle details.
+
#### StopSleepOptions
(); // [!code highlight]
+ const writer = writable.getWriter();
+
+ await writer.write({ turn, text: "done" });
+ writer.releaseLock(); // [!code highlight]
+}
+```
+
+Pass `{ namespace: "name" }` to target a [namespaced stream](#namespaced-streams). The writable can also be forwarded through `start()` and into steps.
+
+
+Contributors should call `releaseLock()`, which flushes pending writes. Calling `close()` closes the shared stream for every writer.
+
+
+The API grants append access, not read access or additional authorization. The owning run controls the stream's lifecycle, and an unknown run rejects with `WorkflowRunNotFoundError`.
+
## Common patterns
### Progress updates for long-running tasks
diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts
index d9699fad35..c298b7dca4 100644
--- a/packages/core/src/runtime.ts
+++ b/packages/core/src/runtime.ts
@@ -161,6 +161,7 @@ export {
Run,
type WorkflowReadableStream,
type WorkflowReadableStreamOptions,
+ type WorkflowRunWritableStreamOptions,
} from './runtime/run.js';
export {
type CancelRunOptions,
diff --git a/packages/core/src/runtime/run-get-writable.test.ts b/packages/core/src/runtime/run-get-writable.test.ts
new file mode 100644
index 0000000000..371c9b9cec
--- /dev/null
+++ b/packages/core/src/runtime/run-get-writable.test.ts
@@ -0,0 +1,325 @@
+import { WorkflowRunNotFoundError } from '@workflow/errors';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { importKey } from '../encryption.js';
+import { bytesToBase64, deriveRunKeyPair } from '../sealed-box.js';
+import {
+ decrypt as decryptEnvelope,
+ runPayloadKeys,
+} from '../serialization/encryption.js';
+import {
+ dehydrateStepArguments,
+ dehydrateWorkflowArguments,
+ hydrateStepArguments,
+ hydrateWorkflowArguments,
+} from '../serialization.js';
+import { hydrateData } from '../serialization-format.js';
+import {
+ STREAM_NAME_SYMBOL,
+ STREAM_SERVER_DEPLOYMENT_ID_SYMBOL,
+ STREAM_SERVER_PUBLIC_KEY_SYMBOL,
+ STREAM_SERVER_RUN_ID_SYMBOL,
+} from '../symbols.js';
+import { getWorldLazy } from './get-world-lazy.js';
+import { getRun, Run } from './run.js';
+
+vi.mock('../version.js', () => ({ version: '0.0.0-test' }));
+vi.mock('./get-world-lazy.js', () => ({ getWorldLazy: vi.fn() }));
+
+const OWNER_RUN_ID = 'wrun_ownerrun';
+const OWNER_STREAM = 'strm_ownerrun_user';
+const OWNER_MATERIAL = new Uint8Array(32).fill(0x2b);
+
+function ownerRun(overrides: Record = {}) {
+ return {
+ runId: OWNER_RUN_ID,
+ status: 'running',
+ deploymentId: 'dpl_owner',
+ ...overrides,
+ };
+}
+
+function mockWorld(overrides: Record = {}) {
+ const world = {
+ runs: { get: vi.fn().mockResolvedValue(ownerRun()) },
+ streams: {
+ write: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ getInfo: vi.fn().mockResolvedValue({ tailIndex: -1, done: false }),
+ },
+ ...overrides,
+ } as any;
+ vi.mocked(getWorldLazy).mockReturnValue(world);
+ return world;
+}
+
+function framesWritten(world: any): Uint8Array[] {
+ return world.streams.write.mock.calls
+ .map((c: any[]) => c[2])
+ .filter((c: unknown) => c instanceof Uint8Array);
+}
+
+afterEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+});
+
+describe('Run#getWritable', () => {
+ it('targets the owner run default stream', async () => {
+ const world = mockWorld();
+
+ const ops: Promise[] = [];
+ const writable = await getRun(OWNER_RUN_ID).getWritable({ ops });
+ const writer = writable.getWriter();
+ await writer.write('from-a-contributor');
+ writer.releaseLock();
+ await Promise.all(ops);
+
+ expect(world.runs.get).toHaveBeenCalledWith(OWNER_RUN_ID, {
+ resolveData: 'none',
+ });
+ expect(world.streams.write).toHaveBeenCalled();
+ for (const call of world.streams.write.mock.calls) {
+ expect(call[0]).toBe(OWNER_RUN_ID);
+ expect(call[1]).toBe(OWNER_STREAM);
+ }
+ });
+
+ it('targets the owner run namespaced stream', async () => {
+ const world = mockWorld();
+
+ const ops: Promise[] = [];
+ const writable = await getRun(OWNER_RUN_ID).getWritable({
+ namespace: 'session-events',
+ ops,
+ });
+ const writer = writable.getWriter();
+ await writer.write('namespaced');
+ writer.releaseLock();
+ await Promise.all(ops);
+
+ // base64url('session-events'), appended to the run's default stream name.
+ const expected = `${OWNER_STREAM}_c2Vzc2lvbi1ldmVudHM`;
+ expect(world.streams.write.mock.calls[0][1]).toBe(expected);
+ expect((writable as any)[STREAM_NAME_SYMBOL]).toBe(expected);
+ });
+
+ it('rejects for a run that does not exist', async () => {
+ const world = mockWorld({
+ runs: {
+ get: vi
+ .fn()
+ .mockRejectedValue(new WorkflowRunNotFoundError('wrun_ghost')),
+ },
+ });
+
+ await expect(getRun('wrun_ghost').getWritable()).rejects.toThrow(
+ WorkflowRunNotFoundError
+ );
+ expect(world.streams.write).not.toHaveBeenCalled();
+ expect(world.runs.get).toHaveBeenCalledTimes(1);
+ });
+
+ it('seals to the owner public key without resolving a symmetric key', async () => {
+ const ownerKeyPair = await deriveRunKeyPair(OWNER_MATERIAL);
+ const getEncryptionKeyForRun = vi.fn();
+ const world = mockWorld({
+ getEncryptionKeyForRun,
+ runs: {
+ get: vi.fn().mockResolvedValue(
+ ownerRun({
+ encryptionPublicKey: bytesToBase64(ownerKeyPair.publicKey),
+ })
+ ),
+ },
+ });
+
+ const ops: Promise[] = [];
+ const writable = await getRun(OWNER_RUN_ID).getWritable({ ops });
+ const writer = writable.getWriter();
+ await writer.write('sealed-to-owner');
+ writer.releaseLock();
+ await Promise.all(ops);
+
+ expect(getEncryptionKeyForRun).not.toHaveBeenCalled();
+
+ const frames = framesWritten(world);
+ expect(frames.length).toBeGreaterThan(0);
+ // [4-byte length][encp][sealed...]
+ expect(new TextDecoder().decode(frames[0].subarray(4, 8))).toBe('encp');
+
+ const ownerKeys = runPayloadKeys(
+ await importKey(OWNER_MATERIAL),
+ ownerKeyPair
+ );
+ const opened = await decryptEnvelope(frames[0].subarray(4), ownerKeys);
+ expect(hydrateData(opened as Uint8Array, {})).toBe('sealed-to-owner');
+ });
+
+ it('falls back to the owner deployment key when it published no public key', async () => {
+ const getEncryptionKeyForRun = vi.fn().mockResolvedValue(OWNER_MATERIAL);
+ const world = mockWorld({ getEncryptionKeyForRun });
+
+ const ops: Promise[] = [];
+ const writable = await getRun(OWNER_RUN_ID).getWritable({ ops });
+ const writer = writable.getWriter();
+ await writer.write('legacy-owner');
+ writer.releaseLock();
+ await Promise.all(ops);
+
+ expect(getEncryptionKeyForRun).toHaveBeenCalledWith(OWNER_RUN_ID, {
+ deploymentId: 'dpl_owner',
+ });
+ expect(world.runs.get).toHaveBeenCalledTimes(1);
+
+ const frames = framesWritten(world);
+ expect(new TextDecoder().decode(frames[0].subarray(4, 8))).toBe('encr');
+ });
+
+ it('carries every forwarding symbol on the returned handle', async () => {
+ const ownerKeyPair = await deriveRunKeyPair(OWNER_MATERIAL);
+ const ownerPublicKey = bytesToBase64(ownerKeyPair.publicKey);
+ mockWorld({
+ runs: {
+ get: vi
+ .fn()
+ .mockResolvedValue(ownerRun({ encryptionPublicKey: ownerPublicKey })),
+ },
+ });
+
+ const writable = await getRun(OWNER_RUN_ID).getWritable();
+
+ expect((writable as any)[STREAM_NAME_SYMBOL]).toBe(OWNER_STREAM);
+ expect((writable as any)[STREAM_SERVER_RUN_ID_SYMBOL]).toBe(OWNER_RUN_ID);
+ expect((writable as any)[STREAM_SERVER_DEPLOYMENT_ID_SYMBOL]).toBe(
+ 'dpl_owner'
+ );
+ expect((writable as any)[STREAM_SERVER_PUBLIC_KEY_SYMBOL]).toBe(
+ ownerPublicKey
+ );
+ });
+
+ it('does not advertise a public key the owner never published', async () => {
+ mockWorld({ getEncryptionKeyForRun: vi.fn().mockResolvedValue(undefined) });
+
+ const writable = await getRun(OWNER_RUN_ID).getWritable();
+
+ expect((writable as any)[STREAM_SERVER_PUBLIC_KEY_SYMBOL]).toBeUndefined();
+ });
+
+ it('keeps the owner identity through start() and into a step', async () => {
+ const ownerKeyPair = await deriveRunKeyPair(OWNER_MATERIAL);
+ const ownerPublicKey = bytesToBase64(ownerKeyPair.publicKey);
+ const world = mockWorld({
+ runs: {
+ get: vi
+ .fn()
+ .mockResolvedValue(ownerRun({ encryptionPublicKey: ownerPublicKey })),
+ },
+ getEncryptionKeyForRun: vi.fn(),
+ });
+
+ const writable = await getRun(OWNER_RUN_ID).getWritable();
+
+ // Hop 1: start() dehydrates into the turn workflow's arguments.
+ const forwarded = await dehydrateWorkflowArguments(
+ writable,
+ 'wrun_turn',
+ undefined
+ );
+ const inWorkflow = (await hydrateWorkflowArguments(
+ forwarded,
+ 'wrun_turn',
+ undefined
+ )) as WritableStream;
+ expect((inWorkflow as any)[STREAM_SERVER_RUN_ID_SYMBOL]).toBe(OWNER_RUN_ID);
+ expect((inWorkflow as any)[STREAM_SERVER_PUBLIC_KEY_SYMBOL]).toBe(
+ ownerPublicKey
+ );
+
+ // Hop 2: the workflow passes it into the step that writes.
+ const toStep = await dehydrateStepArguments(
+ inWorkflow,
+ 'wrun_turn',
+ undefined
+ );
+ const ops: Promise[] = [];
+ const inStep = (await hydrateStepArguments(
+ toStep,
+ 'wrun_turn',
+ undefined,
+ ops,
+ globalThis,
+ {},
+ 'dpl_turn'
+ )) as WritableStream;
+
+ const writer = inStep.getWriter();
+ await writer.write('written-two-hops-away');
+ writer.releaseLock();
+ await Promise.all(ops);
+
+ expect(world.getEncryptionKeyForRun).not.toHaveBeenCalled();
+ const ownerFrames = world.streams.write.mock.calls.filter(
+ (c: any[]) => c[0] === OWNER_RUN_ID && c[1] === OWNER_STREAM
+ );
+ expect(ownerFrames.length).toBeGreaterThan(0);
+
+ const ownerKeys = runPayloadKeys(
+ await importKey(OWNER_MATERIAL),
+ ownerKeyPair
+ );
+ const frame = ownerFrames[0][2] as Uint8Array;
+ const opened = await decryptEnvelope(frame.subarray(4), ownerKeys);
+ expect(hydrateData(opened as Uint8Array, {})).toBe('written-two-hops-away');
+ });
+
+ it('drains on lock release without closing the shared stream', async () => {
+ const world = mockWorld();
+
+ const ops: Promise[] = [];
+ const writable = await getRun(OWNER_RUN_ID).getWritable({ ops });
+ const writer = writable.getWriter();
+ await writer.write('flushed-without-close');
+ writer.releaseLock();
+
+ await Promise.all(ops);
+
+ expect(world.streams.write).toHaveBeenCalled();
+ expect(world.streams.close).not.toHaveBeenCalled();
+ });
+
+ it('closes the owner stream when the caller closes the handle', async () => {
+ const world = mockWorld();
+
+ const ops: Promise[] = [];
+ const writable = await getRun(OWNER_RUN_ID).getWritable({ ops });
+ const writer = writable.getWriter();
+ await writer.write('last-chunk');
+ await writer.close();
+ await Promise.all(ops);
+
+ expect(world.streams.close).toHaveBeenCalledWith(
+ OWNER_RUN_ID,
+ OWNER_STREAM
+ );
+ });
+
+ it('retries a missing run only for a resiliently started run', async () => {
+ vi.useFakeTimers();
+ const runsGet = vi
+ .fn()
+ .mockRejectedValueOnce(new WorkflowRunNotFoundError(OWNER_RUN_ID))
+ .mockResolvedValue(ownerRun());
+ mockWorld({ runs: { get: runsGet } });
+
+ const pending = new Run(OWNER_RUN_ID, {
+ resilientStart: true,
+ }).getWritable();
+
+ await vi.advanceTimersByTimeAsync(1_000);
+ const writable = await pending;
+
+ expect(runsGet).toHaveBeenCalledTimes(2);
+ expect((writable as any)[STREAM_SERVER_RUN_ID_SYMBOL]).toBe(OWNER_RUN_ID);
+ });
+});
diff --git a/packages/core/src/runtime/run.ts b/packages/core/src/runtime/run.ts
index 2f116612fe..2eddef3b98 100644
--- a/packages/core/src/runtime/run.ts
+++ b/packages/core/src/runtime/run.ts
@@ -13,6 +13,8 @@ import {
type PayloadKey,
} from '../serialization/encryption.js';
import {
+ createForwardedWritable,
+ getForwardedWritableEncryptionKey,
getRunReadableStream,
hydrateRunError,
hydrateWorkflowReturnValue,
@@ -32,6 +34,9 @@ const PAYLOAD_TERMINAL_RUN_STATUSES = new Set([
'failed',
]);
+// Give resilient starts up to 10 seconds to create the run.
+const NOT_FOUND_RETRY_DELAYS = [1_000, 3_000, 6_000];
+
/** @internal */
export function getReturnValuePollIntervalMs(): number {
return envNumber(
@@ -154,6 +159,33 @@ export interface WorkflowReadableStreamOptions {
global?: Record;
}
+/**
+ * Options for configuring a writable onto a workflow run's stream.
+ */
+export interface WorkflowRunWritableStreamOptions {
+ /**
+ * An optional namespace to distinguish between multiple streams associated
+ * with the same workflow run.
+ */
+ namespace?: string;
+ /**
+ * Any asynchronous operations to complete before pausing or terminating the
+ * execution environment
+ * (i.e. using [`waitUntil()`](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil) or similar).
+ *
+ * Writes are acknowledged on buffer entry, so a caller that needs them
+ * durable before the environment goes away should pass an array here and
+ * await it after releasing the writer lock.
+ */
+ ops?: Promise[];
+ /**
+ * The global object to use for reducing types from the global scope.
+ *
+ * Defaults to {@link [`globalThis`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/globalThis)}.
+ */
+ global?: Record;
+}
+
/**
* A handler class for a workflow run.
*/
@@ -394,6 +426,64 @@ export class Run {
});
}
+ /**
+ * Returns a writable that appends to this run's stream.
+ *
+ * The run must already exist. The writable may be forwarded through `start()`
+ * and into steps, but grants no read access or additional authorization.
+ *
+ * @remarks
+ * `writer.close()` closes the shared stream. Contributors should call
+ * `releaseLock()`, which also drains pending writes.
+ *
+ * @param options - The writable stream options.
+ * @throws WorkflowRunNotFoundError if the run does not exist.
+ */
+ async getWritable(
+ options: WorkflowRunWritableStreamOptions = {}
+ ): Promise> {
+ 'use step';
+ const { ops = [], global = globalThis, namespace } = options;
+ const run = await this.#getMetadata();
+ const name = getWorkflowRunStreamId(this.runId, namespace);
+
+ // Resolve before returning so key lookup failures precede accepted writes.
+ const key = await getForwardedWritableEncryptionKey(
+ this.runId,
+ run.deploymentId,
+ run.encryptionPublicKey
+ );
+
+ return createForwardedWritable({
+ global,
+ ops,
+ runId: this.runId,
+ name,
+ key,
+ deploymentId: run.deploymentId,
+ encryptionPublicKey: run.encryptionPublicKey,
+ });
+ }
+
+ /** Reads metadata, briefly retrying resilient starts. @internal */
+ async #getMetadata() {
+ const world = await this.#lazyWorldPromise;
+ const maxRetries = this.#resilientStart ? NOT_FOUND_RETRY_DELAYS.length : 0;
+ let attempt = 0;
+ while (true) {
+ try {
+ return await world.runs.get(this.runId, { resolveData: 'none' });
+ } catch (error) {
+ if (!WorkflowRunNotFoundError.is(error) || attempt >= maxRetries) {
+ throw error;
+ }
+ const delay = NOT_FOUND_RETRY_DELAYS[attempt]!;
+ attempt++;
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ }
+ }
+
/** @internal */
async #resolveTerminalReturnValue(run: WorkflowRun): Promise {
if (run.status === 'completed') {
@@ -451,8 +541,9 @@ export class Run {
// and the runtime to create the run via run_started.
// When resilientStart is false, 404 is a real error: fail fast.
let notFoundRetries = 0;
- const NOT_FOUND_MAX_RETRIES = this.#resilientStart ? 3 : 0;
- const NOT_FOUND_DELAYS = [1_000, 3_000, 6_000];
+ const NOT_FOUND_MAX_RETRIES = this.#resilientStart
+ ? NOT_FOUND_RETRY_DELAYS.length
+ : 0;
// Prefer the World's long poll: one read that the backend holds open
// until the run finishes, instead of asking again every second and
@@ -531,7 +622,7 @@ export class Run {
WorkflowRunNotFoundError.is(error) &&
notFoundRetries < NOT_FOUND_MAX_RETRIES
) {
- const delay = NOT_FOUND_DELAYS[notFoundRetries]!;
+ const delay = NOT_FOUND_RETRY_DELAYS[notFoundRetries]!;
notFoundRetries++;
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index 542d672f63..828fe4ce1c 100644
--- a/packages/core/src/serialization.ts
+++ b/packages/core/src/serialization.ts
@@ -2753,24 +2753,11 @@ export function getCommonRevivers(global: Record = globalThis) {
}
/**
- * Resolve the write-only key a run needs when writing into another run's
- * forwarded stream.
- *
- * Three tiers, cheapest first:
- *
- * 1. The descriptor carries the owner's X25519 public key: seal to it with
- * no I/O whatsoever. The owner published the key when it created the
- * stream, so this is the zero-round-trip path.
- * 2. The descriptor carries the owner's deployment ID: resolve the owner's
- * symmetric key, which cross-deployment means a key-API round trip.
- * 3. Neither (descriptors written by older SDKs): load the owning run first,
- * then resolve its symmetric key.
- *
- * Tiers 2 and 3 import the key encrypt-only, which is an honor-system
- * restriction: the same bytes could decrypt. Tier 1 makes it a cryptographic
- * guarantee: a public key cannot read anything.
+ * Resolves the owner's encryption key, preferring its public key, then its
+ * deployment, and finally its run record.
+ * @internal
*/
-async function getForwardedWritableEncryptionKey(
+export async function getForwardedWritableEncryptionKey(
runId: string,
deploymentId: string | undefined,
encryptionPublicKey: string | undefined
@@ -2787,6 +2774,76 @@ async function getForwardedWritableEncryptionKey(
return rawKey ? await importKey(rawKey, ['encrypt']) : undefined;
}
+/** Creates and tags a forwarded writable targeting the owner run. @internal */
+export function createForwardedWritable({
+ global,
+ ops,
+ runId,
+ name,
+ key,
+ deploymentId,
+ encryptionPublicKey,
+ runReadyBarrier,
+}: {
+ global: Record;
+ ops: Promise[];
+ runId: string;
+ name: string;
+ key: EncryptionKeyParam;
+ deploymentId?: string;
+ encryptionPublicKey?: string;
+ runReadyBarrier?: Promise;
+}): WritableStream {
+ const serialize = getSerializeStream(
+ getExternalReducers(global, ops, runId, key),
+ key
+ );
+ const serverWritable = new WorkflowServerWritableStream(
+ runId,
+ name,
+ runReadyBarrier
+ );
+
+ // Lock release must settle the flush promise; contributors do not close.
+ const state = createFlushableState();
+ ops.push(state.promise);
+
+ flushablePipe(serialize.readable, serverWritable, state).catch(() => {
+ // Errors are handled via state.reject
+ });
+
+ pollWritableLock(serialize.writable, state);
+
+ Object.defineProperty(serialize.writable, STREAM_NAME_SYMBOL, {
+ value: name,
+ writable: false,
+ });
+ Object.defineProperty(serialize.writable, STREAM_SERVER_RUN_ID_SYMBOL, {
+ value: runId,
+ writable: false,
+ });
+ if (typeof deploymentId === 'string') {
+ Object.defineProperty(
+ serialize.writable,
+ STREAM_SERVER_DEPLOYMENT_ID_SYMBOL,
+ {
+ value: deploymentId,
+ writable: false,
+ }
+ );
+ }
+ // Keep the owner's public key on the handle so a further forward stays on
+ // the zero-lookup sealed path.
+ if (typeof encryptionPublicKey === 'string') {
+ Object.defineProperty(serialize.writable, STREAM_SERVER_PUBLIC_KEY_SYMBOL, {
+ value: encryptionPublicKey,
+ writable: false,
+ });
+ }
+
+ return serialize.writable as WritableStream;
+}
+
/**
* Create a run's object readable without dispatching its stream GET or
* encryption-key lookup until the caller reads it. The external reviver starts
@@ -3014,59 +3071,15 @@ export function getExternalRevivers(
value.encryptionPublicKey
);
- const serialize = getSerializeStream(
- getExternalReducers(global, ops, targetRunId, targetKey),
- targetKey
- );
- const serverWritable = new WorkflowServerWritableStream(
- targetRunId,
- value.name
- );
-
- // Create flushable state for this stream
- const state = createFlushableState();
- ops.push(state.promise);
-
- // Start the flushable pipe in the background
- flushablePipe(serialize.readable, serverWritable, state).catch(() => {
- // Errors are handled via state.reject
- });
-
- // Start polling to detect when user releases lock
- pollWritableLock(serialize.writable, state);
-
- Object.defineProperty(serialize.writable, STREAM_NAME_SYMBOL, {
- value: value.name,
- writable: false,
- });
- Object.defineProperty(serialize.writable, STREAM_SERVER_RUN_ID_SYMBOL, {
- value: targetRunId,
- writable: false,
+ return createForwardedWritable({
+ global,
+ ops,
+ runId: targetRunId,
+ name: value.name,
+ key: targetKey,
+ deploymentId: value.deploymentId,
+ encryptionPublicKey: value.encryptionPublicKey,
});
- if (typeof value.deploymentId === 'string') {
- Object.defineProperty(
- serialize.writable,
- STREAM_SERVER_DEPLOYMENT_ID_SYMBOL,
- {
- value: value.deploymentId,
- writable: false,
- }
- );
- }
- // Keep the owner's public key on the handle so a further forward stays on
- // the zero-lookup sealed path.
- if (typeof value.encryptionPublicKey === 'string') {
- Object.defineProperty(
- serialize.writable,
- STREAM_SERVER_PUBLIC_KEY_SYMBOL,
- {
- value: value.encryptionPublicKey,
- writable: false,
- }
- );
- }
-
- return serialize.writable;
},
AbortController: (value) => reviveAbortController(value, ops, runId),
diff --git a/packages/workflow/src/api-workflow.ts b/packages/workflow/src/api-workflow.ts
index a489fd929d..c8ec15a9e6 100644
--- a/packages/workflow/src/api-workflow.ts
+++ b/packages/workflow/src/api-workflow.ts
@@ -6,6 +6,7 @@ export type {
StopSleepResult,
WorkflowReadableStreamOptions,
WorkflowRun,
+ WorkflowRunWritableStreamOptions,
} from '@workflow/core/runtime';
export { Run } from '@workflow/core/runtime/run';
diff --git a/packages/workflow/src/api.ts b/packages/workflow/src/api.ts
index 31ca6ed146..f16eedadce 100644
--- a/packages/workflow/src/api.ts
+++ b/packages/workflow/src/api.ts
@@ -25,6 +25,7 @@ export {
Run,
type WorkflowReadableStream,
type WorkflowReadableStreamOptions,
+ type WorkflowRunWritableStreamOptions,
} from '@workflow/core/runtime/run';
export {
type StartOptions,