Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
14beb59
feat(task-graph): binary-streaming framework + result-as-reference
claude Jun 4, 2026
542ab77
feat(job-queue): in-process stream observability via JobHandle.onStream
claude Jun 4, 2026
c0ff05c
fix(task-graph): brand CacheRef with literal kind to prevent shape-on…
claude Jun 9, 2026
0370ec5
fix(task-graph): canonicalize binary stream format vocabulary to "blo…
claude Jun 9, 2026
62da9e2
fix(task-graph): byte-bounded backpressure in binary stream router (d…
claude Jun 9, 2026
d76baae
feat(task-graph): streaming read helpers for cache refs
claude Jun 10, 2026
1947225
feat(task-graph): resolveJobOutputStream for streaming job results fr…
claude Jun 10, 2026
861e7c2
feat(task-graph): expose binary stream-consumer detection for cache-h…
claude Jun 10, 2026
85ac20b
feat(task-graph): cache-hit replay and hydration of binary cache refs
claude Jun 10, 2026
1e9f7d2
feat(task-graph): hydrate cache refs in task inputs before execute
claude Jun 10, 2026
a4113bd
feat(task-graph): FsFolderTaskOutputRepository with streaming blob si…
claude Jun 10, 2026
14bbea2
feat(job-queue): capability-gated JobHandle.outputStream for cached b…
claude Jun 10, 2026
21e6b2b
docs(task-graph): document binary cache stream-out in EXECUTION_MODEL
claude Jun 10, 2026
8effced
fix(task-graph): blob lifecycle hardening in FsFolderTaskOutputReposi…
claude Jun 11, 2026
6bbc2ef
refactor(task-graph): remove dead pipeBinaryToCache; detach edge-stre…
claude Jun 11, 2026
99504d1
fix(task-graph): cache rows store refs, not inline binary; enforce si…
claude Jun 11, 2026
68b2f1b
fix(task-graph): default blob/binary port codecs for JSON-row cache b…
claude Jun 11, 2026
0ddb022
fix(task-graph): guard resolveOutput walker against cycles and shared…
claude Jun 12, 2026
63cdfe0
fix(task-graph): treat Error and URL as opaque leaves in ref walker
claude Jun 12, 2026
49dc9bf
fix(task-graph): fsync blob temp handle before rename in FsFolderTask…
claude Jun 12, 2026
290f13a
fix(task-graph): clean up orphan blobs when stream-write succeeds but…
claude Jun 12, 2026
b55c46a
fix(task-graph): document single-tenant assumption of FsFolderTaskOut…
claude Jun 12, 2026
48679f0
fix(task-graph): treat all class instances as opaque in ref walker
claude Jun 13, 2026
566c656
fix(task-graph): fsync blobs directory after rename to survive crashe…
claude Jun 13, 2026
28e2e14
fix(task-graph): stamp FsFolder blobs with unique suffix to prevent o…
claude Jun 13, 2026
12f6835
refactor(task-graph): extract BackpressureGate from BinaryStreamRouter
claude Jun 24, 2026
549c1ff
fix(job-queue): drop stale classToStorage import in JobQueueWorker
claude Jun 24, 2026
9f882b4
fix(task-graph): replace-mode streams must carry a value, else error
claude Jun 24, 2026
4f19d1f
feat(task-graph): add optional port/mode axis to CacheRef
claude Jun 24, 2026
facae57
feat(task-graph): per-mode stream codecs + port-aware repo stream sink
claude Jun 24, 2026
1fcb722
Merge remote-tracking branch 'origin/main' into claude/relaxed-farada…
claude Jun 25, 2026
33b9ba9
test(task-graph): lock delta-wins on the StreamProcessor path
claude Jun 25, 2026
c1ac0e9
feat(task-graph): generalize stream sinks to all modes (per-port refs)
claude Jun 25, 2026
037b1c1
feat(task-graph): thread noAccumulation run flag to the streaming seam
claude Jun 27, 2026
4d88326
feat(task-graph): exempt stream-wired input ports from whole-value va…
claude Jun 27, 2026
204f0b5
feat(task-graph): no-accumulation passthrough — skip the materialize …
claude Jun 27, 2026
505d4cc
feat(task-graph): cache-hit replay parity for non-binary refs (Task 6)
claude Jun 30, 2026
c746442
feat(task-graph): backpressure gate on the no-accumulation passthroug…
claude Jul 2, 2026
b45932f
test(task-graph): widen Uint8Array to ArrayBuffer-backed for Blob con…
claude Jul 2, 2026
21ee863
fix(task-graph): correctness fixes from branch-wide streaming review
claude Jul 2, 2026
3b86a44
Merge origin/main into streaming branch; reconcile cache run-scoping
claude Jul 2, 2026
3036a1d
fix(test): cast Uint8Array to BlobPart in streaming-port test repos
claude Jul 2, 2026
3f3bb72
chore(task-graph): re-baseline typecheck-budget for streaming framework
claude Jul 2, 2026
d443441
Merge origin/claude/relaxed-faraday-ql3olr (main reconciled) into str…
claude Jul 2, 2026
79a84e6
Merge pull request #607 from workglow-dev/claude/task-graph-streaming…
sroussey Jul 2, 2026
e99badf
feat(task-graph): stream the run-private cache tier over an FsFolder …
claude Jul 2, 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
7 changes: 5 additions & 2 deletions packages/ai/src/task/base/AiTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,10 @@ export class AiTask<
/**
* Validates that model inputs are valid ModelConfig objects.
*/
public override async validateInput(input: Input): Promise<boolean> {
public override async validateInput(
input: Input,
skipPorts?: ReadonlySet<string>
): Promise<boolean> {
const inputSchema = this.inputSchema();
if (typeof inputSchema === "boolean") {
if (inputSchema === false) {
Expand Down Expand Up @@ -422,7 +425,7 @@ export class AiTask<
}
}

return super.validateInput(input);
return super.validateInput(input, skipPorts);
}

public override async narrowInput(input: Input, registry: ServiceRegistry): Promise<Input> {
Expand Down
7 changes: 5 additions & 2 deletions packages/ai/src/task/generation/ImageEditTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,11 @@ export class ImageEditTask extends AiImageOutputTask<ImageEditTaskInput, ImageEd
return ImageEditOutputSchema as DataPortSchema;
}

public override async validateInput(input: ImageEditTaskInput): Promise<boolean> {
const ok = await super.validateInput(input);
public override async validateInput(
input: ImageEditTaskInput,
skipPorts?: ReadonlySet<string>
): Promise<boolean> {
const ok = await super.validateInput(input, skipPorts);
if (!ok) return false;
await this.validateProviderImageInput(input);
return true;
Expand Down
7 changes: 5 additions & 2 deletions packages/ai/src/task/generation/ImageGenerateTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,11 @@ export class ImageGenerateTask extends AiImageOutputTask<
return ImageGenerateOutputSchema as DataPortSchema;
}

public override async validateInput(input: ImageGenerateTaskInput): Promise<boolean> {
const ok = await super.validateInput(input);
public override async validateInput(
input: ImageGenerateTaskInput,
skipPorts?: ReadonlySet<string>
): Promise<boolean> {
const ok = await super.validateInput(input, skipPorts);
if (!ok) return false;
await this.validateProviderImageInput(input);
return true;
Expand Down
9 changes: 3 additions & 6 deletions packages/indexeddb/src/job-queue/IndexedDbQueueStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,7 @@ export class IndexedDbQueueStorage<Input, Output> implements IQueueStorage<Input
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const job = request.result as
| (JobStorageFormat<Input, Output> & Record<string, unknown>)
| undefined;
(JobStorageFormat<Input, Output> & Record<string, unknown>) | undefined;
if (job && job.queue === this.queueName && this.matchesPrefixes(job)) {
resolve(job);
} else {
Expand Down Expand Up @@ -416,8 +415,7 @@ export class IndexedDbQueueStorage<Input, Output> implements IQueueStorage<Input
const getReq = store.get(job.id as string);
getReq.onsuccess = () => {
const existing = getReq.result as
| (JobStorageFormat<Input, Output> & Record<string, unknown>)
| undefined;
(JobStorageFormat<Input, Output> & Record<string, unknown>) | undefined;
if (!existing || existing.queue !== this.queueName || !this.matchesPrefixes(existing)) {
// Contract: complete() must silently no-op (not throw) when the row is
// missing or belongs to another queue. Lease-expiry races can legitimately
Expand Down Expand Up @@ -654,8 +652,7 @@ export class IndexedDbQueueStorage<Input, Output> implements IQueueStorage<Input
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const job = request.result as
| (JobStorageFormat<Input, Output> & Record<string, unknown>)
| undefined;
(JobStorageFormat<Input, Output> & Record<string, unknown>) | undefined;
if (job && this.matchesPrefixes(job)) {
resolve(job.output ?? null);
} else {
Expand Down
8 changes: 7 additions & 1 deletion packages/job-queue/src/job/Job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { JobStatus } from "../queue-storage/IQueueStorage";
import { JobError } from "./JobError";
import type { JobProgressListener } from "./JobQueueEventListeners";
import type { JobProgressListener, StreamEventLike } from "./JobQueueEventListeners";

export { JobStatus };

Expand All @@ -20,6 +20,12 @@ export interface IJobExecuteContext {
message?: string,
details?: Record<string, any> | null
) => Promise<void>;
/**
* OPTIONAL. Present only when the worker's transport can deliver stream
* events. Jobs MUST NOT retain references to chunk buffers after calling
* this (buffers may be transferred across a worker boundary and detached).
*/
emitStreamEvent?: (event: StreamEventLike) => void;
}

/**
Expand Down
104 changes: 103 additions & 1 deletion packages/job-queue/src/job/JobQueueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
JobQueueEventListeners,
JobQueueEventParameters,
JobQueueEvents,
JobStreamListener,
type StreamEventLike,
} from "./JobQueueEventListeners";
import type { JobQueueServer } from "./JobQueueServer";
import { storageToClass } from "./JobStorageConverters";
Expand All @@ -38,15 +40,50 @@ export interface JobHandle<Output> {
waitFor(): Promise<Output>;
abort(): Promise<void>;
onProgress(callback: JobProgressListener): () => void;
/**
* OPTIONAL — present only when this handle's transport can deliver stream
* events (a same-process server-attached queue). Absent on storage-only
* backends; callers branch on `typeof handle.onStream === "function"`.
*/
onStream?(callback: JobStreamListener): () => void;
/**
* OPTIONAL — present only when the client was configured with an
* `outputStreamResolver` (an output cache backing reachable from this
* process). Awaits the job's completion, then streams the binary result
* back out of the output cache without materializing it. `port` selects the
* output port; omit it when the output holds exactly one cache ref.
* Resolves `undefined` when there is nothing binary to stream (or the
* cache entry was evicted).
*/
outputStream?(port?: string): Promise<AsyncIterable<Uint8Array> | undefined>;
}

/**
* Resolves a completed job's output value to a byte stream. Injected via
* {@link JobQueueClientOptions.outputStreamResolver} because the cache layer
* that understands output refs lives above this package in the dependency
* graph (`@workglow/task-graph` exports `makeJobOutputStreamResolver` to
* build one from a cache backing).
*/
export type JobOutputStreamResolver = (
output: unknown,
port?: string
) => Promise<AsyncIterable<Uint8Array> | undefined>;

/**
* Options for creating a JobQueueClient
*/
export interface JobQueueClientOptions<Input, Output> {
readonly messageQueue: IMessageQueue<JobStorageFormat<Input, Output>>;
readonly jobStore: IJobStore<Input, Output>;
readonly queueName: string;
/**
* OPTIONAL — enables `JobHandle.outputStream` on handles from this client.
* Deployments whose output cache backing is reachable from this process
* inject a resolver (see `makeJobOutputStreamResolver` in
* `@workglow/task-graph`); without it, handles omit the method.
*/
readonly outputStreamResolver?: JobOutputStreamResolver;
}

/**
Expand All @@ -61,6 +98,7 @@ export class JobQueueClient<Input, Output> {
protected readonly events = new EventEmitter<JobQueueEventListeners<Input, Output>>();
protected server: JobQueueServer<Input, Output> | null = null;
protected storageUnsubscribe: (() => void) | null = null;
protected readonly outputStreamResolver: JobOutputStreamResolver | undefined;

/**
* Map of job IDs to their pending promise resolvers
Expand All @@ -78,6 +116,11 @@ export class JobQueueClient<Input, Output> {
*/
protected readonly jobProgressListeners: Map<unknown, Set<JobProgressListener>> = new Map();

/**
* Map of job IDs to their stream listeners
*/
protected readonly jobStreamListeners: Map<unknown, Set<JobStreamListener>> = new Map();

/**
* Last known progress state for each job
*/
Expand All @@ -94,6 +137,7 @@ export class JobQueueClient<Input, Output> {
this.queueName = options.queueName;
this.messageQueue = options.messageQueue;
this.jobStore = options.jobStore;
this.outputStreamResolver = options.outputStreamResolver;
}

/**
Expand Down Expand Up @@ -431,6 +475,27 @@ export class JobQueueClient<Input, Output> {
};
}

/**
* Subscribe to stream events for a specific job
*/
public onJobStream(jobId: unknown, listener: JobStreamListener): () => void {
if (!this.jobStreamListeners.has(jobId)) {
this.jobStreamListeners.set(jobId, new Set());
}
const listeners = this.jobStreamListeners.get(jobId)!;
listeners.add(listener);

return () => {
const listeners = this.jobStreamListeners.get(jobId);
if (listeners) {
listeners.delete(listener);
if (listeners.size === 0) {
this.jobStreamListeners.delete(jobId);
}
}
};
}

// ========================================================================
// Event handling
// ========================================================================
Expand Down Expand Up @@ -564,23 +629,60 @@ export class JobQueueClient<Input, Output> {
}
}

/**
* Called by server when a job emits a stream event. Listener throws are
* isolated per-listener — one misbehaving subscriber does not interrupt
* delivery to the rest or abort the dispatch itself.
* @internal
*/
public handleJobStream(jobId: unknown, event: StreamEventLike): void {
this.events.emit("job_stream", this.queueName, jobId, event);

const listeners = this.jobStreamListeners.get(jobId);
if (!listeners) return;
for (const listener of listeners) {
try {
listener(event);
} catch (err) {
getLogger().error("JobHandle.onStream listener threw", {
jobId,
error: err instanceof Error ? err.message : String(err),
});
}
}
}

// ========================================================================
// Private helpers
// ========================================================================

private createJobHandle(id: unknown): JobHandle<Output> {
return {
const handle: JobHandle<Output> = {
id,
waitFor: () => this.waitFor(id),
abort: () => this.abort(id),
onProgress: (callback: JobProgressListener) => this.onJobProgress(id, callback),
};
// Stream delivery requires a same-process server-attached transport — the
// same signal `connect()` uses. Storage-only backends omit `onStream`, so
// callers branch on `typeof handle.onStream === "function"`.
if (this.server) {
handle.onStream = (callback: JobStreamListener) => this.onJobStream(id, callback);
}
// Streaming result reads require a cache backing reachable from this
// process; the injected resolver is the capability signal.
const resolver = this.outputStreamResolver;
if (resolver) {
handle.outputStream = async (port?: string) => resolver(await this.waitFor(id), port);
}
return handle;
}

private cleanupJob(jobId: unknown): void {
this.activeJobPromises.delete(jobId);
this.lastKnownProgress.delete(jobId);
this.jobProgressListeners.delete(jobId);
this.jobStreamListeners.delete(jobId);
}

private handleStorageChange(change: QueueChangePayload<Input, Output>): void {
Expand Down
14 changes: 14 additions & 0 deletions packages/job-queue/src/job/JobQueueEventListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type JobQueueEventListeners<Input, Output> = {
message: string,
details: Record<string, any> | null
) => void;
job_stream: (queueName: string, jobId: unknown, event: StreamEventLike) => void;
};

export type JobQueueEvents = keyof JobQueueEventListeners<any, any>;
Expand All @@ -46,3 +47,16 @@ export type JobProgressListener = (
message: string,
details: Record<string, any> | null
) => void;

/**
* Minimal structural shape of a stream event crossing the job-queue boundary.
*
* `@workglow/job-queue` sits below `@workglow/task-graph` in the dependency
* graph, so it cannot import task-graph's `StreamEvent`. This structural type
* captures just what the queue plumbing needs; task-graph's `StreamEvent` is
* assignable to it, so real stream producers interoperate transparently.
*/
export type StreamEventLike = { type: string; port?: string; [k: string]: unknown };

/** Listener for cross-process stream events emitted by an executing job. */
export type JobStreamListener = (event: StreamEventLike) => void;
12 changes: 12 additions & 0 deletions packages/job-queue/src/job/JobQueueServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { JobStatus } from "../queue-storage/IQueueStorage";
import type { DeadLetter } from "./DeadLetter";
import { Job, JobClass } from "./Job";
import { JobQueueClient } from "./JobQueueClient";
import type { StreamEventLike } from "./JobQueueEventListeners";
import { JobQueueWorker } from "./JobQueueWorker";
import { storageToClass } from "./JobStorageConverters";

Expand Down Expand Up @@ -57,6 +58,7 @@ export type JobQueueServerEventListeners<Input, Output> = {
message: string,
details: Record<string, unknown> | null
) => void;
job_stream: (queueName: string, jobId: unknown, event: StreamEventLike) => void;
};

export type JobQueueServerEvents = keyof JobQueueServerEventListeners<unknown, unknown>;
Expand Down Expand Up @@ -501,6 +503,11 @@ export class JobQueueServer<
this.forwardToClients("handleJobProgress", jobId, progress, message, details);
});

worker.on("job_stream", (jobId, event) => {
this.events.emit("job_stream", this.queueName, jobId, event);
this.forwardToClients("handleJobStream", jobId, event);
});

return worker;
}

Expand All @@ -524,6 +531,11 @@ export class JobQueueServer<
message: string,
details: Record<string, unknown> | null
): void;
protected forwardToClients(
method: "handleJobStream",
jobId: unknown,
event: StreamEventLike
): void;
protected forwardToClients(method: string, ...args: unknown[]): void {
for (const client of this.clients) {
const fn = (client as any)[method];
Expand Down
14 changes: 14 additions & 0 deletions packages/job-queue/src/job/JobQueueWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
RetryableJobError,
} from "./JobError";
import { withJobErrorDiagnostics } from "./JobErrorDiagnostics";
import type { StreamEventLike } from "./JobQueueEventListeners";
import { storageToClass } from "./JobStorageConverters";

/**
Expand Down Expand Up @@ -71,6 +72,7 @@ export type JobQueueWorkerEventListeners<Input, Output> = {
message: string,
details: Record<string, unknown> | null
) => void;
job_stream: (jobId: unknown, event: StreamEventLike) => void;
worker_start: () => void;
worker_stop: () => void;
};
Expand Down Expand Up @@ -856,6 +858,7 @@ export class JobQueueWorker<
return await job.execute(job.input, {
signal,
updateProgress: this.updateProgress.bind(this, job.id),
emitStreamEvent: (event) => this.emitStreamEvent(job.id, event),
});
}

Expand All @@ -877,6 +880,17 @@ export class JobQueueWorker<
this.events.emit("job_progress", jobId, progress, message, details);
}

/**
* Emit a cross-process stream event for a job.
*
* Mirrors {@link updateProgress}: stream events are delivered in-memory via
* the `job_stream` event and forwarded by an attached `JobQueueServer` to
* subscribed clients. Storage is not touched.
*/
protected emitStreamEvent(jobId: unknown, event: StreamEventLike): void {
this.events.emit("job_stream", jobId, event);
}

/** Internal — resolve the active claim for a job id, throw if missing. */
private getClaim(jobId: unknown): IClaim<JobStorageFormat<Input, Output>> | undefined {
return this.activeClaims.get(jobId);
Expand Down
3 changes: 1 addition & 2 deletions packages/storage/src/tabular/HttpTabularProxyStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ export interface HttpTabularProxyOptions<
readonly schema: Schema;
readonly primaryKey: PrimaryKeyNames;
readonly indexes?: readonly (
| keyof Schema["properties"]
| readonly (keyof Schema["properties"])[]
keyof Schema["properties"] | readonly (keyof Schema["properties"])[]
)[];
/** Optional base path. Defaults to `/api/storage`. Trailing slashes are stripped. */
readonly basePath?: string;
Expand Down
Loading
Loading